/** * SMTP Email Service Implementation * * Implements EmailService over any SMTP relay via nodemailer: the provider a * sovereign self-host uses instead of Resend (US), e.g. a Swedish mail * provider, a byrÄ's own Microsoft 365 / Google Workspace relay, or Postfix * on the host. Selected by EMAIL_PROVIDER=smtp (or auto-detected from * SMTP_HOST when no RESEND_API_KEY is set): see email-provider.ts. * * Environment: * SMTP_HOST required * SMTP_PORT default 587 * SMTP_SECURE "true" = implicit TLS (port 465); default * false = STARTTLS, required by default * SMTP_REQUIRE_TLS default true: with SMTP_SECURE=false the * session MUST upgrade to TLS via STARTTLS * before AUTH and mail (nodemailer's own * default is opportunistic, which lets an * on-path STARTTLS-stripping attacker read * the credentials and every invoice PDF). * "false" only for a plaintext LAN relay. * SMTP_USER / SMTP_PASS optional (an internal relay may be open to * the Docker network only) * SMTP_FROM_EMAIL required: the envelope/From address * SMTP_TLS_REJECT_UNAUTHORIZED default true; "false" accepts a relay's * self-signed certificate (LAN relays only) * * The From header is built exactly like the Resend service does it, with the * same header-injection defence, so a customer sees the same sender shape * whichever provider the operator picked. */ import nodemailer, { type Transporter } from 'nodemailer' import { createLogger } from '@/lib/logger' import { getBranding } from '@/lib/branding/service' import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service' const log = createLogger('email-smtp') function sanitizeHeaderPart(s: string): string { return s.replace(/[\r\n<>]/g, '').trim() } // RFC 5322 "specials" that make a bare display name ambiguous (a comma splits // the mailbox list, a quote or parenthesis opens a token). Mirrors // resend-service.ts so both providers emit the same From shape. const DISPLAY_NAME_SPECIALS = /[()<>[\]:;@\\,."]/ /** Quote a display name only when RFC 5322 requires it; escape `\` and `"`. */ function encodeDisplayName(name: string): string { if (!DISPLAY_NAME_SPECIALS.test(name)) return name return `"${name.replace(/[\\"]/g, (c) => `\\${c}`)}"` } // Same conservative shape as resend-service.ts: a last-line guard against a // malformed company_sending_domains row, not an RFC 5322 parser. const FROM_ADDRESS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}@[a-z0-9.-]{4,253}$/ /** * Builds the From header exactly like resend-service.ts buildFromHeader(). * With an explicit `from` (the company's own verified sending domain, #1802) * the mail leaves as "
" and the relay's default sender is not * involved. `fromAddress` is only ever set by lib/email/brand-sender.ts for * VERIFIED brand sender domains (WL-13): " ". Otherwise * the platform default: " " or * " ". A fromName WITHOUT an explicit address shows the * name ALONE (founder call 2026-08-05: no "via " in the display * name; the platform stays visible in the actual From address until a sender * domain is verified). A malformed explicit sender falls through to the * platform default rather than failing the send. * * Mirrored rather than imported because the platform fallback address differs * per provider (RESEND_FROM_EMAIL there, SMTP_FROM_EMAIL here). Same * header-injection defence: CRLF and angle brackets are stripped from every * name and address part. Exported for unit tests. */ export function buildSmtpFromHeader(input: { fromName?: string from?: { name: string; address: string } fromAddress?: string defaultFromEmail: string }): string { const safeAppName = sanitizeHeaderPart(getBranding().appName) if (input.from) { const address = input.from.address.trim().toLowerCase() const name = sanitizeHeaderPart(input.from.name) if (FROM_ADDRESS_PATTERN.test(address) && name) { return `${encodeDisplayName(name)} <${address}>` } } const safeFromName = input.fromName ? sanitizeHeaderPart(input.fromName) : null const safeFromAddress = input.fromAddress ? sanitizeHeaderPart(input.fromAddress) : null if (safeFromAddress) { return safeFromName ? `${encodeDisplayName(safeFromName)} <${safeFromAddress}>` : safeFromAddress } return safeFromName ? `${encodeDisplayName(safeFromName)} <${input.defaultFromEmail}>` : `${encodeDisplayName(safeAppName)} <${input.defaultFromEmail}>` } function optionalAddressList(addresses: string | string[] | undefined): string[] | undefined { if (!addresses) return undefined const list = Array.isArray(addresses) ? addresses : [addresses] return list.length > 0 ? list : undefined } function envBool(name: string, fallback: boolean): boolean { const v = process.env[name]?.trim().toLowerCase() if (v === undefined || v === '') return fallback if (v === 'true' || v === '1' || v === 'yes') return true if (v === 'false' || v === '0' || v === 'no') return false return fallback } export interface SmtpSettings { host: string port: number secure: boolean user: string | null pass: string | null fromEmail: string rejectUnauthorized: boolean /** STARTTLS is mandatory (default). Only false for a plaintext LAN relay. */ requireTLS: boolean } /** Read the SMTP settings from the environment; null when the minimum (host + from) is missing. */ export function readSmtpSettings(): SmtpSettings | null { const host = process.env.SMTP_HOST?.trim() const fromEmail = process.env.SMTP_FROM_EMAIL?.trim() if (!host || !fromEmail) return null const parsedPort = Number(process.env.SMTP_PORT) const secure = envBool('SMTP_SECURE', false) return { host, port: Number.isFinite(parsedPort) && parsedPort > 0 ? Math.floor(parsedPort) : secure ? 465 : 587, secure, user: process.env.SMTP_USER?.trim() || null, pass: process.env.SMTP_PASS ?? null, fromEmail, rejectUnauthorized: envBool('SMTP_TLS_REJECT_UNAUTHORIZED', true), requireTLS: envBool('SMTP_REQUIRE_TLS', true), } } export function isSmtpConfigured(): boolean { return readSmtpSettings() !== null } let cachedTransport: { key: string; transport: Transporter } | null = null function getTransport(settings: SmtpSettings): Transporter { const key = JSON.stringify({ ...settings, pass: settings.pass ? 'set' : 'unset' }) if (cachedTransport && cachedTransport.key === key) return cachedTransport.transport const transport = nodemailer.createTransport({ host: settings.host, port: settings.port, secure: settings.secure, // nodemailer's default STARTTLS is opportunistic: it silently carries on in // cleartext when the relay (or an on-path attacker stripping the STARTTLS // capability) does not offer TLS. Refuse that unless the operator opted // out for a plaintext LAN relay. Irrelevant with implicit TLS (secure). requireTLS: !settings.secure && settings.requireTLS, ...(settings.user ? { auth: { user: settings.user, pass: settings.pass ?? '' } } : {}), tls: { rejectUnauthorized: settings.rejectUnauthorized }, }) cachedTransport = { key, transport } return transport } /** Tests only: forget the cached transport so the next send re-reads the environment. */ export function resetSmtpTransportForTests(): void { cachedTransport = null } export class SmtpEmailService implements EmailService { async sendEmail(options: SendEmailOptions): Promise { const { to, cc, bcc, subject, html, text, replyTo, fromName, fromAddress, attachments } = options const settings = readSmtpSettings() if (!settings) { return { success: false, error: 'Email service is not configured' } } const from = buildSmtpFromHeader({ fromName, from: options.from, fromAddress, defaultFromEmail: settings.fromEmail }) // The retry deliberately drops both the company sender and the brand // address so it differs from `from` whenever either was set. const platformFrom = buildSmtpFromHeader({ fromName, defaultFromEmail: settings.fromEmail }) const mail = { to: Array.isArray(to) ? to : [to], cc: optionalAddressList(cc), bcc: optionalAddressList(bcc), subject, html, text, replyTo, attachments: attachments?.map((att) => ({ filename: att.filename, content: typeof att.content === 'string' ? Buffer.from(att.content, 'base64') : Buffer.from(att.content), contentType: att.contentType, })), } try { const transport = getTransport(settings) let info: { messageId?: string } try { info = await transport.sendMail({ from, ...mail }) } catch (error) { // Same fallback as the Resend service: a relay may refuse to send as // an address it does not own (e.g. a Microsoft 365 "send as" policy). // The relay rejected the message, so nothing went out: retry once as // the platform sender rather than failing every invoice for that // company. if (from === platformFrom) throw error log.warn('SMTP relay rejected the company sender, retrying as the platform sender', { from, error: error instanceof Error ? error.message : String(error), }) info = await transport.sendMail({ from: platformFrom, ...mail }) } return { success: true, provider: 'smtp', messageId: info.messageId } } catch (error) { log.error('Failed to send email over SMTP:', error) return { success: false, provider: 'smtp', error: error instanceof Error ? error.message : 'Unknown error', } } } isConfigured(): boolean { return isSmtpConfigured() } }