SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated.
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
/**
|
|
* 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 "<name> <address>" 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): "<fromName> <fromAddress>". Otherwise
|
|
* the platform default: "<fromName> <SMTP_FROM_EMAIL>" or
|
|
* "<App> <SMTP_FROM_EMAIL>". A fromName WITHOUT an explicit address shows the
|
|
* name ALONE (founder call 2026-08-05: no "via <platform>" 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<SendEmailResult> {
|
|
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()
|
|
}
|
|
}
|