Skip to content

Replace nodemailer with a Bun-native SMTP client (subsumes #37, #42, parts of #25) #60

Description

@mohamedboukari

Why

Nodemailer is well-tested and keeps BunMail shipping today, but it's the largest single source of friction in our delivery story:

  • No TLS observability. The info object returned from sendMail() has no tls field. To capture cipher / version / validation status you have to reach into private members of SMTPConnection or write a custom transport. Tracked separately as Missing structured outbound TLS metrics #37 — punting until this lands.
  • Embeds Go binaries via transitive deps. esbuild gets pulled in via drizzle-kit, which Trivy scans flagged for 9 Go-stdlib CVEs (Harden CI with security scanning layers (Trivy, gitleaks, Semgrep) #54). Move drizzle-kit out of the runtime Docker image (esbuild ships in production) #56 plans to remove drizzle-kit from runtime, but esbuild also rides through the build via nodemailer-express style plugins; long term we'd rather not have this on our supply chain at all.
  • No per-domain requireTLS. Nodemailer's requireTLS is global; we'd want a per-recipient policy (require for *.bank.com, opportunistic everywhere else). Self-signed cert on outbound (rejectUnauthorized: false) #42 is currently blocked on this.
  • Connection pooling is opaque. A single Transporter reuses connections by default, but we recreate the transport per message to handle per-recipient MX. With our own client we could do real connection pooling and amortise STARTTLS across batched sends.
  • Bigger node_modules + slower cold start. Nodemailer pulls mailcomposer, addressparser, libmime, libqp, etc. — ~120 deps for what's ultimately a few SMTP commands plus MIME framing.

A small, focused client built on Bun.connect() and tls.connect() from Node compat would give us:

  • Direct access to the post-handshake TLSSocket → trivial capture of protocol, cipher, authorized per send
  • Per-domain TLS policy hook (tlsPolicy(recipientDomain): "opportunistic" | "require" | "skip")
  • Connection pool keyed on (mxHost, port) with idle eviction
  • Smaller dep footprint — only need the MIME composer (could keep mailparser's sister package or write the ~200 lines)
  • A clear seam for No suppression list #25 (suppression list) to skip suppressed recipients before opening the socket

Scope (acceptance criteria)

  • New src/modules/emails/services/smtp-client.ts implementing a minimal SMTP client:
    • EHLO / STARTTLS / AUTH / MAIL FROM / RCPT TO / DATA / QUIT
    • Per-message send({ envelope, message: stream }) → Promise<{ messageId, response, tls }>
    • tls is { used, version, cipher, validated, peerCertSubject }
  • MIME composer extracted from nodemailer or written from scratch (~200 lines):
    • Headers (incl. DKIM signing, List-Unsubscribe, Message-ID, Date)
    • Multipart/alternative for html + text
    • Future: attachments (separate issue, not in v1)
  • Connection pool: Map<"mx:port", Connection[]> with TTL-based eviction
  • Per-domain TLS policy: tls_policy column on domains (opportunistic default, require opt-in)
  • mailer.service.ts rewires to the new client
  • nodemailer + @types/nodemailer removed from package.json
  • Per-send TLS state logged in the existing "Email sent successfully" log line — closes Missing structured outbound TLS metrics #37
  • Per-domain requireTLS enforced when tls_policy = require and STARTTLS unavailable — closes Self-signed cert on outbound (rejectUnauthorized: false) #42
  • Trivy image scan no longer flags the Go-stdlib CVEs that came in via esbuild-via-nodemailer (also helps close Move drizzle-kit out of the runtime Docker image (esbuild ships in production) #56's residual surface)

Non-goals (file separately if needed)

  • TLS metrics aggregation in a /api/v1/stats endpoint — that's the dashboard widget side of Missing structured outbound TLS metrics #37 and can ship after the client lands
  • Outbound IPv6 support (nodemailer is IPv4-only; native client could do better, but only after MX-resolution improvements)
  • Inbound SMTP rewrite — smtp-server (the inbound side) is a separate library and out of scope here

Phasing

This is too big for one PR. Suggested order:

  1. MIME composer — extract or write. Standalone, unit-testable, no behaviour change yet. Covered by tests against canonical RFC 822 fixtures.
  2. SMTP client — new module, no integration. Tests against a local SMTP fake (smtp-server in test mode).
  3. Per-domain TLS policy — schema column + DTO. Defaults to opportunistic, no behaviour change.
  4. Mailer rewire — flip mailer.service.ts to the new client. Drop nodemailer import. Per-send TLS log lands here. Closes Missing structured outbound TLS metrics #37, Self-signed cert on outbound (rejectUnauthorized: false) #42.
  5. Connection pool — observability only first (log when reused vs new). Then enable.

Each phase should be its own PR.

Open questions

  • MIME composer: extract from mailparser's ecosystem, write from scratch, or use letterparser? Decision before phase 1.
  • DKIM: use Node's crypto directly (we already have the keys in PEM)? Or pull nodemailer/lib/dkim? Decision before phase 1.
  • Charset / encoding: stick with UTF-8 + 8BITMIME assumption, or support non-8-bit fallback? Affects composer complexity. Default to 8BITMIME for v1.

Linked issues:

Metadata

Metadata

Assignees

No one assigned

    Labels

    deliverabilityEmail deliverability (inbox vs spam)featureNew featureobservabilityLogs, metrics, tracingpriority:highHigh-priority workrefactorInternal refactor, no behaviour change

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions