diff --git a/package.json b/package.json index a2e94be17..cff5b9625 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "pg-boss": "^12.18.2", "prisma": "^7.8.0", "qrcode": "^1.5.4", + "nodemailer": "^9.0.0", "radix-ui": "^1.4.3", "react": "19.2.7", "react-dom": "19.2.7", @@ -100,6 +101,7 @@ "@tailwindcss/postcss": "^4", "@testcontainers/postgresql": "^12.0.1", "@types/node": "^25.9.2", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f00356f57..36bc054e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + nodemailer: + specifier: ^9.0.0 + version: 9.0.0 p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -153,6 +156,9 @@ importers: '@types/node': specifier: ^25.9.2 version: 25.9.2 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -2857,6 +2863,9 @@ packages: '@types/node@25.9.2': resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} @@ -5187,6 +5196,10 @@ packages: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + nodemailer@9.0.0: + resolution: {integrity: sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==} + engines: {node: '>=6.0.0'} + non-error@0.1.0: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} engines: {node: '>=20'} @@ -9335,13 +9348,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -9360,6 +9373,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 18.19.130 + '@types/pako@2.0.4': {} '@types/pg@8.20.0': @@ -9389,15 +9406,15 @@ snapshots: '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2@0.5.52': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -11823,6 +11840,8 @@ snapshots: node-releases@2.0.46: {} + nodemailer@9.0.0: {} + non-error@0.1.0: {} normalize-path@3.0.0: {} @@ -12244,7 +12263,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.2 + '@types/node': 18.19.130 long: 5.3.2 proxy-addr@2.0.7: diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts new file mode 100644 index 000000000..f75ca2da2 --- /dev/null +++ b/src/app/api/settings/email/route.ts @@ -0,0 +1,82 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { emailSettingsSchema } from "@/lib/validations/notifications"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; + +/** + * Email channel config (v1.17.1). + * GET: per-user recipient + opt-in, plus `smtpConfigured` so the UI can hide + * the card on an instance where the operator hasn't set `SMTP_*`. + * PUT: upsert recipient + enabled. + * + * The SMTP transport (host/port/auth/from) is operator-supplied via env; only + * the recipient address is per-user, so SMTP credentials never enter a user's + * encrypted channel blob. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const smtpConfigured = isEmailConfigured(); + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "EMAIL" } }, + }); + + if (!channel) { + return apiSuccess({ enabled: false, recipient: "", smtpConfigured }); + } + + const config = JSON.parse(decrypt(channel.config)) as { recipient: string }; + + annotate({ action: { name: "settings.email.get" } }); + + return apiSuccess({ + enabled: channel.enabled, + recipient: config.recipient, + smtpConfigured, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + if (!isEmailConfigured()) { + return apiError("Email is not configured on this instance", 400); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + + if (jsonError) return jsonError; + const parsed = emailSettingsSchema.safeParse(body); + if (!parsed.success) return apiError("Invalid data", 422); + + const { recipient, enabled } = parsed.data; + + if (enabled && !recipient) { + return apiError("A recipient address is required when email is enabled", 422); + } + + const config = JSON.stringify({ recipient: recipient || "" }); + const encryptedConfig = encrypt(config); + + await prisma.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "EMAIL" } }, + create: { + userId: user.id, + type: "EMAIL", + enabled, + config: encryptedConfig, + }, + update: { enabled, config: encryptedConfig }, + }); + + annotate({ action: { name: "settings.email.update" }, meta: { enabled } }); + + return apiSuccess({ saved: true }); +}); diff --git a/src/app/api/settings/email/test/route.ts b/src/app/api/settings/email/test/route.ts new file mode 100644 index 000000000..33d6bb522 --- /dev/null +++ b/src/app/api/settings/email/test/route.ts @@ -0,0 +1,54 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { sendViaEmail } from "@/lib/notifications/senders/email"; +import type { EmailChannelConfig } from "@/lib/notifications/types"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; + +/** + * POST: send a test email to the configured recipient. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + if (!isEmailConfigured()) { + return apiError("Email is not configured on this instance", 400); + } + + const rl = await checkRateLimit(`email-test:${user.id}`, 5, 5 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximum 5 tests in 5 minutes", 429); + } + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "EMAIL" } }, + }); + + if (!channel) { + return apiError("Email is not configured", 400); + } + + const config = JSON.parse(decrypt(channel.config)) as EmailChannelConfig; + + if (!config.recipient) { + return apiError("A recipient address is required", 400); + } + + const result = await sendViaEmail(config, { + eventType: "SYSTEM_ALERT", + userId: user.id, + title: "HealthLog Test", + message: "HealthLog: Connection successful! Email notifications are active.", + }); + + if (!result.ok) { + return apiError("Failed to send test email", 500); + } + + annotate({ action: { name: "settings.email.test" }, meta: { success: true } }); + + return apiSuccess({ sent: true }); +}); diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx new file mode 100644 index 000000000..f0b5b5a90 --- /dev/null +++ b/src/components/settings/email-card.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Mail } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; + +interface EmailSettings { + enabled: boolean; + recipient: string; + /** Operator-side: false when SMTP_* env is unset (card hides itself). */ + smtpConfigured: boolean; +} + +export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [recipient, setRecipient] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + const { data: settings } = useQuery({ + queryKey: queryKeys.settingsEmail(), + queryFn: async () => { + return apiGet("/api/settings/email"); + }, + enabled: isAuthenticated, + }); + + // React-recommended sync-from-server pattern (no setState-in-effect). + const settingsKey = settings ? settings.recipient : null; + const [seededKey, setSeededKey] = useState(null); + if (settingsKey !== null && settingsKey !== seededKey) { + setSeededKey(settingsKey); + setRecipient(settings!.recipient); + } + + const save = useMutation({ + mutationFn: async (enabled: boolean) => { + const res = await apiFetchRaw("/api/settings/email", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipient, enabled }), + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); + setSaveMsg(t("settings.saved")); + setSaveMsgType("success"); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + // Operator hasn't configured SMTP — no transport, so no card. The dispatcher + // skips the channel too; surfacing an unusable toggle would only confuse. + if (settings && !settings.smtpConfigured) return null; + + return ( +
+ + +
+
+ + save.mutate(checked)} + disabled={save.isPending} + /> +
+ +
{ + e.preventDefault(); + save.mutate(settings?.enabled ?? false); + }} + className="space-y-4" + > +
+ + setRecipient(e.target.value)} + /> +
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/notifications/senders/__tests__/email-config.test.ts b/src/lib/notifications/senders/__tests__/email-config.test.ts new file mode 100644 index 000000000..4698fc353 --- /dev/null +++ b/src/lib/notifications/senders/__tests__/email-config.test.ts @@ -0,0 +1,84 @@ +/** + * SMTP config loader unit tests (v1.17.1). Mirrors the APNs env-gating + * contract: zero set → disabled (no warning), partial set → disabled (warning), + * all three core vars → configured. SMTP_USER/SMTP_PASS are optional. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { + loadEmailConfig, + isEmailConfigured, + resetEmailConfigForTesting, +} from "../email-config"; + +const ORIGINAL_ENV = { ...process.env }; + +function clearSmtp() { + for (const key of [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_FROM", + "SMTP_USER", + "SMTP_PASS", + "SMTP_SECURE", + ]) { + delete process.env[key]; + } +} + +beforeEach(() => { + clearSmtp(); + resetEmailConfigForTesting(); +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + resetEmailConfigForTesting(); +}); + +describe("loadEmailConfig", () => { + it("returns null when nothing is set", () => { + expect(loadEmailConfig()).toBeNull(); + expect(isEmailConfigured()).toBe(false); + }); + + it("returns null when only some core vars are set (partial)", () => { + process.env.SMTP_HOST = "smtp.example.com"; + resetEmailConfigForTesting(); + expect(loadEmailConfig()).toBeNull(); + }); + + it("builds a config from the three core vars (no auth)", () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "587"; + process.env.SMTP_FROM = "noreply@example.com"; + resetEmailConfigForTesting(); + + const config = loadEmailConfig(); + expect(config).toEqual({ + host: "smtp.example.com", + port: 587, + secure: false, + from: "noreply@example.com", + }); + expect(isEmailConfigured()).toBe(true); + }); + + it("includes auth when user + pass are set and honours SMTP_SECURE", () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "465"; + process.env.SMTP_FROM = "noreply@example.com"; + process.env.SMTP_USER = "u"; + process.env.SMTP_PASS = "p"; + process.env.SMTP_SECURE = "true"; + resetEmailConfigForTesting(); + + const config = loadEmailConfig(); + expect(config?.secure).toBe(true); + expect(config?.auth).toEqual({ user: "u", pass: "p" }); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts new file mode 100644 index 000000000..53066c67a --- /dev/null +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -0,0 +1,140 @@ +/** + * SMTP / email sender unit tests (v1.17.1). + * + * Covers: success, transient SMTP failure (4xx/connection — backoff-eligible), + * hard reject (5xx permanent — channel auto-disables), the "SMTP not + * configured" soft-skip, the "no recipient" soft-skip, and the ledger write. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMailMock = vi.fn(); +const createTransportMock = vi.fn((..._args: unknown[]) => ({ + sendMail: sendMailMock, +})); +const loadEmailConfigMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: (...args: unknown[]) => createTransportMock(...args), + }, +})); + +vi.mock("@/lib/notifications/senders/email-config", () => ({ + loadEmailConfig: () => loadEmailConfigMock(), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), +})); + +import { + sendViaEmail, + resetEmailTransporterForTesting, +} from "../email"; + +const transport = { + host: "smtp.example.com", + port: 587, + secure: false, + from: "HealthLog ", + auth: { user: "u", pass: "p" }, +}; + +function payload(over?: Record) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Subject", + message: "Body", + ...over, + }; +} + +beforeEach(() => { + sendMailMock.mockReset(); + createTransportMock.mockClear(); + loadEmailConfigMock.mockReset(); + recordPushAttemptMock.mockReset(); + resetEmailTransporterForTesting(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("sendViaEmail", () => { + it("sends a plain-text email and returns ok", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockResolvedValue({ messageId: "1" }); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(true); + const mail = sendMailMock.mock.calls[0][0]; + expect(mail.to).toBe("you@example.com"); + expect(mail.from).toBe(transport.from); + // Body is plain text — HTML stripped, no `html` field. + expect(mail.text).toBe("Body"); + expect(mail.html).toBeUndefined(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "EMAIL", result: "ok" }), + ); + }); + + it("soft-skips when SMTP is unconfigured (no channel burn)", async () => { + loadEmailConfigMock.mockReturnValue(null); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("email_not_configured"); + expect(sendMailMock).not.toHaveBeenCalled(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "EMAIL", result: "skipped" }), + ); + }); + + it("soft-skips when the recipient is empty", async () => { + loadEmailConfigMock.mockReturnValue(transport); + + const result = await sendViaEmail({ recipient: "" }, payload()); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("email_no_recipient"); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it("hard-rejects on an SMTP 5xx (permanent — auto-disable)", async () => { + loadEmailConfigMock.mockReturnValue(transport); + const err = Object.assign(new Error("550 mailbox unavailable"), { + responseCode: 550, + }); + sendMailMock.mockRejectedValue(err); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("email_smtp_5xx"); + }); + + it("soft-fails on a connection error (transient — backoff-eligible)", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockRejectedValue(new Error("ECONNREFUSED")); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("email_smtp_error"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "EMAIL", result: "error" }), + ); + }); +}); diff --git a/src/lib/notifications/senders/email-config.ts b/src/lib/notifications/senders/email-config.ts new file mode 100644 index 000000000..0a7eacd8b --- /dev/null +++ b/src/lib/notifications/senders/email-config.ts @@ -0,0 +1,78 @@ +import { getEvent } from "@/lib/logging/context"; + +/** + * Operator-supplied SMTP transport config (v1.17.1). + * + * Mirrors the APNs pattern (`loadApnsConfig`): the transport credentials live + * once in the operator's env, NOT in every user's encrypted channel blob. When + * the required vars are absent the channel is simply never offered — a + * self-hoster who hasn't set up SMTP sees no Email card and the dispatcher + * skips the channel cleanly rather than throwing on every send. + * + * Required: SMTP_HOST, SMTP_PORT, SMTP_FROM. + * Optional: SMTP_USER + SMTP_PASS (omit for an unauthenticated relay), + * SMTP_SECURE ("true" → implicit TLS on connect, typically port 465; + * default false → STARTTLS upgrade, typically port 587). + */ +export interface EmailTransportConfig { + host: string; + port: number; + secure: boolean; + from: string; + auth?: { user: string; pass: string }; +} + +let cachedConfig: EmailTransportConfig | null | undefined; + +export function loadEmailConfig(): EmailTransportConfig | null { + if (cachedConfig !== undefined) return cachedConfig; + + const host = process.env.SMTP_HOST?.trim() || ""; + const portRaw = process.env.SMTP_PORT?.trim() || ""; + const from = process.env.SMTP_FROM?.trim() || ""; + const user = process.env.SMTP_USER?.trim() || ""; + const pass = process.env.SMTP_PASS?.trim() || ""; + const secure = process.env.SMTP_SECURE?.trim().toLowerCase() === "true"; + + // All-or-none guard mirroring APNs: zero set → silently disabled (no + // warning). Some-but-not-all set → the operator started configuring and + // stopped; surface that as a warning so the half-finished state is visible + // the moment a send is attempted. + const anySet = Boolean(host || portRaw || from || user || pass); + const port = Number(portRaw); + const allSet = Boolean(host && portRaw && from && Number.isFinite(port)); + + if (!anySet) { + cachedConfig = null; + return null; + } + + if (!allSet) { + getEvent()?.addWarning( + "SMTP config incomplete — set SMTP_HOST, SMTP_PORT, and SMTP_FROM " + + "together (SMTP_USER/SMTP_PASS optional). Email channel is disabled " + + "until all three are present.", + ); + cachedConfig = null; + return null; + } + + cachedConfig = { + host, + port, + secure, + from, + ...(user && pass ? { auth: { user, pass } } : {}), + }; + return cachedConfig; +} + +/** True when the operator has configured an SMTP transport. */ +export function isEmailConfigured(): boolean { + return loadEmailConfig() !== null; +} + +/** Test-only: clear the per-process cache so env changes take effect. */ +export function resetEmailConfigForTesting(): void { + cachedConfig = undefined; +} diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts new file mode 100644 index 000000000..289c7490b --- /dev/null +++ b/src/lib/notifications/senders/email.ts @@ -0,0 +1,167 @@ +import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; + +import type { + EmailChannelConfig, + NotificationPayload, +} from "@/lib/notifications/types"; +import type { SendOutcome } from "@/lib/notifications/retry-policy"; +import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { loadEmailConfig } from "@/lib/notifications/senders/email-config"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; + +/** + * SMTP / email sender (v1.17.1). + * + * The SMTP transport is OPERATOR-configured via `SMTP_*` env (see + * `email-config.ts`); the per-user config carries only the recipient address. + * When SMTP is unconfigured the send returns a soft `skipped` outcome and the + * dispatcher never surfaces the channel (the Settings card is hidden too). + * + * Body is PLAIN TEXT — no markdown library (hard rule). Routine reminders run + * through `plainPushText` so the colour-coded phase emoji don't leak into the + * subject/body; SYSTEM_ALERTs keep their severity glyphs. + * + * Transport errors map to a transient `SendOutcome` so the dispatcher's + * backoff/auto-disable machinery absorbs a flapping mail server exactly like + * the HTTP channels. nodemailer surfaces `err.responseCode` for SMTP-level + * rejections; a 5xx permanent rejection (bad recipient, relay denied) is a + * hard reject so the channel auto-disables rather than retrying forever. + */ + +// Cache one transporter per process keyed on the resolved transport config. +// Rebuilding a transporter per send would re-establish the connection pool on +// every dispatch; nodemailer transports are safe to reuse. +let cachedTransporter: Transporter | undefined; +let cachedTransportKey: string | undefined; + +function getTransporter(): { transporter: Transporter; from: string } | null { + const config = loadEmailConfig(); + if (!config) return null; + + const key = JSON.stringify({ + host: config.host, + port: config.port, + secure: config.secure, + hasAuth: Boolean(config.auth), + }); + if (cachedTransporter && cachedTransportKey === key) { + return { transporter: cachedTransporter, from: config.from }; + } + + cachedTransporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + ...(config.auth ? { auth: config.auth } : {}), + }); + cachedTransportKey = key; + return { transporter: cachedTransporter, from: config.from }; +} + +/** Test-only: drop the cached transporter so a re-configured env takes effect. */ +export function resetEmailTransporterForTesting(): void { + cachedTransporter = undefined; + cachedTransportKey = undefined; +} + +function classifySmtpError(err: unknown): { + hardReject: boolean; + reason: string; + message: string; +} { + const message = err instanceof Error ? err.message : "smtp_send_failed"; + // nodemailer attaches `responseCode` (the SMTP reply code) on protocol-level + // rejections. 5xx = permanent (bad mailbox, relay denied) → hard reject so + // the channel auto-disables. 4xx / connection errors = transient → retry. + const responseCode = + typeof err === "object" && err !== null && "responseCode" in err + ? (err as { responseCode?: number }).responseCode + : undefined; + if (typeof responseCode === "number" && responseCode >= 500) { + return { hardReject: true, reason: "email_smtp_5xx", message }; + } + return { hardReject: false, reason: "email_smtp_error", message }; +} + +export async function sendViaEmail( + config: EmailChannelConfig, + payload: NotificationPayload, +): Promise { + const start = performance.now(); + + const transport = getTransporter(); + if (!transport) { + // Operator hasn't configured SMTP — soft skip, no channel burn. + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "skipped", + reason: "email_not_configured", + }); + return { ok: false, hardReject: false, reason: "email_not_configured" }; + } + + if (!config.recipient) { + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "skipped", + reason: "email_no_recipient", + }); + return { ok: false, hardReject: false, reason: "email_no_recipient" }; + } + + // Plain text only — strip HTML + decorative emoji on routine reminders. + const subject = plainPushText(payload.title, payload.eventType); + const text = plainPushText( + payload.message.replace(/<[^>]*>/g, ""), + payload.eventType, + ); + + try { + await transport.transporter.sendMail({ + from: transport.from, + to: config.recipient, + subject, + text, + }); + + getEvent()?.addExternalCall({ + service: "email", + method: "sendMail", + duration_ms: Math.round(performance.now() - start), + }); + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true }; + } catch (err) { + const classified = classifySmtpError(err); + getEvent()?.addExternalCall({ + service: "email", + method: "sendMail", + duration_ms: Math.round(performance.now() - start), + error: classified.message, + }); + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); + return { + ok: false, + hardReject: classified.hardReject, + reason: classified.reason, + message: classified.message, + }; + } +}