mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
7532bbcc05
commit
909c2aa99e
9 files changed
+774
-6
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+25
-6
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user