smtp.ts81 lines · main
| 1 | import nodemailer, { type Transporter } from 'nodemailer'; |
| 2 | |
| 3 | import { env } from '../env.js'; |
| 4 | import { log } from './logger.js'; |
| 5 | |
| 6 | /** |
| 7 | * Generic SMTP fallback transport for transactional email. |
| 8 | * |
| 9 | * This is the second leg of the send chain in `lib/email.ts`: mittera.eu |
| 10 | * is the primary sender, and this SMTP transport engages only when mittera |
| 11 | * is unconfigured or a mittera send fails. It exists so sign-in / magic-link |
| 12 | * / OTP mail keeps flowing while mittera's own sender is sandbox-limited. |
| 13 | * |
| 14 | * Deliberately provider-agnostic — it speaks plain SMTP, so it works with a |
| 15 | * self-run mailserver or any provider's SMTP endpoint. No lock-in, and in |
| 16 | * particular no dependency on a competitor of mittera itself. The operator |
| 17 | * supplies `BRIVEN_SMTP_*` credentials whenever they're ready; until then |
| 18 | * `smtpConfigured()` returns false and the chain falls through to the |
| 19 | * existing stdout-only dev behavior. |
| 20 | */ |
| 21 | |
| 22 | /** True only when every var the SMTP transport needs is present. */ |
| 23 | export function smtpConfigured(): boolean { |
| 24 | return Boolean( |
| 25 | env.BRIVEN_SMTP_HOST && |
| 26 | env.BRIVEN_SMTP_PORT && |
| 27 | env.BRIVEN_SMTP_USER && |
| 28 | env.BRIVEN_SMTP_PASS && |
| 29 | env.BRIVEN_SMTP_FROM, |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | let transporter: Transporter | null = null; |
| 34 | |
| 35 | /** |
| 36 | * Lazily build (and cache) the nodemailer transport. Implicit TLS is used |
| 37 | * for port 465 (the SMTPS convention); every other port negotiates STARTTLS |
| 38 | * via `requireTLS`, so credentials never cross the wire in cleartext. |
| 39 | */ |
| 40 | function getTransport(): Transporter { |
| 41 | if (transporter) return transporter; |
| 42 | const port = env.BRIVEN_SMTP_PORT!; |
| 43 | transporter = nodemailer.createTransport({ |
| 44 | host: env.BRIVEN_SMTP_HOST!, |
| 45 | port, |
| 46 | secure: port === 465, |
| 47 | requireTLS: port !== 465, |
| 48 | auth: { user: env.BRIVEN_SMTP_USER!, pass: env.BRIVEN_SMTP_PASS! }, |
| 49 | }); |
| 50 | return transporter; |
| 51 | } |
| 52 | |
| 53 | export interface SmtpSendArgs { |
| 54 | to: string; |
| 55 | subject: string; |
| 56 | html: string; |
| 57 | text: string; |
| 58 | /** |
| 59 | * Optional From: override (a tenant's verified sender). For deliverability |
| 60 | * the supplied domain must be authorized on the fallback SMTP server; if it |
| 61 | * isn't, drop the override so the message sends from `BRIVEN_SMTP_FROM`. |
| 62 | * The primary (mittera) path handles per-tenant senders properly once it's |
| 63 | * out of its sandbox — this fallback prioritizes "the mail arrives". |
| 64 | */ |
| 65 | from?: string; |
| 66 | } |
| 67 | |
| 68 | /** Send one message through the SMTP fallback. Throws on transport failure. */ |
| 69 | export async function sendViaSmtp(args: SmtpSendArgs): Promise<void> { |
| 70 | const info = await getTransport().sendMail({ |
| 71 | from: args.from ?? env.BRIVEN_SMTP_FROM!, |
| 72 | sender: env.BRIVEN_SMTP_FROM!, |
| 73 | to: args.to, |
| 74 | subject: args.subject, |
| 75 | html: args.html, |
| 76 | text: args.text, |
| 77 | }); |
| 78 | // Recipient is NOT logged (CLAUDE.md §5.1); the messageId is the |
| 79 | // correlation key an operator greps for to confirm a fallback send. |
| 80 | log.info('smtp_send_ok', { messageId: info.messageId }); |
| 81 | } |