You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
Why
Nodemailer is well-tested and keeps BunMail shipping today, but it's the largest single source of friction in our delivery story:
infoobject returned fromsendMail()has notlsfield. To capture cipher / version / validation status you have to reach into private members ofSMTPConnectionor write a custom transport. Tracked separately as Missing structured outbound TLS metrics #37 — punting until this lands.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 vianodemailer-expressstyle plugins; long term we'd rather not have this on our supply chain at all.requireTLS. Nodemailer'srequireTLSis 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.Transporterreuses 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.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()andtls.connect()from Node compat would give us:TLSSocket→ trivial capture ofprotocol,cipher,authorizedper sendtlsPolicy(recipientDomain): "opportunistic" | "require" | "skip")(mxHost, port)with idle evictionmailparser's sister package or write the ~200 lines)Scope (acceptance criteria)
src/modules/emails/services/smtp-client.tsimplementing a minimal SMTP client:send({ envelope, message: stream }) → Promise<{ messageId, response, tls }>tlsis{ used, version, cipher, validated, peerCertSubject }Map<"mx:port", Connection[]>with TTL-based evictiontls_policycolumn ondomains(opportunisticdefault,requireopt-in)mailer.service.tsrewires to the new clientnodemailer+@types/nodemailerremoved frompackage.jsonrequireTLSenforced whentls_policy = requireand STARTTLS unavailable — closes Self-signed cert on outbound (rejectUnauthorized: false) #42Non-goals (file separately if needed)
/api/v1/statsendpoint — that's the dashboard widget side of Missing structured outbound TLS metrics #37 and can ship after the client landssmtp-server(the inbound side) is a separate library and out of scope herePhasing
This is too big for one PR. Suggested order:
smtp-serverin test mode).opportunistic, no behaviour change.mailer.service.tsto the new client. Dropnodemailerimport. Per-send TLS log lands here. Closes Missing structured outbound TLS metrics #37, Self-signed cert on outbound (rejectUnauthorized: false) #42.Each phase should be its own PR.
Open questions
mailparser's ecosystem, write from scratch, or useletterparser? Decision before phase 1.cryptodirectly (we already have the keys in PEM)? Or pullnodemailer/lib/dkim? Decision before phase 1.Linked issues: