smtp.ts81 lines · main
1import nodemailer, { type Transporter } from 'nodemailer';
2
3import { env } from '../env.js';
4import { 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. */
23export 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
33let 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 */
40function 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
53export 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. */
69export 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}