feat(notifications): add SMTP email channel

Alerts and reminders over email — the one channel every self-hoster
already has. The SMTP transport (host/port/auth/from) is operator-supplied
via SMTP_* env and loaded once per process like the APNs key; the only
per-user value is the recipient address, so SMTP credentials never enter a
user's encrypted channel blob. Absent SMTP env disables the channel
cleanly: the dispatcher skips it and the Settings card hides itself.

Bodies are plain text — HTML is stripped and routine reminders run through
the emoji-strip path; no markdown library is added. SMTP 5xx maps to a
hard reject (permanent — auto-disable); connection/4xx errors are
transient and ride the backoff schedule. Adds nodemailer (the standard,
well-audited mail transport) and its types.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:46:12 +02:00
1 parent 7532bbcc05
commit 909c2aa99e
9 files changed
+774 -6

No files matched your search

+2
View File
@@ -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",
+25 -6
View File
@@ -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:
+82
View File
@@ -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 });
});
+54
View File
@@ -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 });
});
+142
View File
@@ -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<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsEmail(),
queryFn: async () => {
return apiGet<EmailSettings>("/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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Mail}
title={t("settings.email")}
description={t("settings.emailDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="email-recipient">
{t("settings.emailRecipient")}
</Label>
<Input
id="email-recipient"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/email/test"
disabled={!settings?.enabled}
/>
<Button type="submit" disabled={save.isPending} className="min-h-11">
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -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" });
});
});
@@ -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 <noreply@example.com>",
auth: { user: "u", pass: "p" },
};
function payload(over?: Record<string, unknown>) {
return {
eventType: "SYSTEM_ALERT" as const,
userId: "user-1",
title: "Subject",
message: "<b>Body</b>",
...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" }),
);
});
});
@@ -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;
}
+167
View File
@@ -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<SendOutcome> {
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,
};
}
}