diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts index 53066c67a..e78403306 100644 --- a/src/lib/notifications/senders/__tests__/email.test.ts +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -8,15 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendMailMock = vi.fn(); -const createTransportMock = vi.fn((..._args: unknown[]) => ({ - sendMail: sendMailMock, -})); +const createTransportMock = vi.fn((options: unknown) => { + void options; + return { sendMail: sendMailMock }; +}); const loadEmailConfigMock = vi.fn(); const recordPushAttemptMock = vi.fn(); vi.mock("nodemailer", () => ({ default: { - createTransport: (...args: unknown[]) => createTransportMock(...args), + createTransport: (options: unknown) => createTransportMock(options), }, })); @@ -86,6 +87,23 @@ describe("sendViaEmail", () => { ); }); + it("builds the transport with explicit SMTP timeouts (no unbounded hang)", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockResolvedValue({ messageId: "1" }); + + await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + const opts = createTransportMock.mock.calls[0][0] as { + connectionTimeout?: number; + greetingTimeout?: number; + socketTimeout?: number; + }; + expect(opts.connectionTimeout).toBe(10_000); + expect(opts.greetingTimeout).toBe(10_000); + expect(opts.socketTimeout).toBe(20_000); + }); + it("soft-skips when SMTP is unconfigured (no channel burn)", async () => { loadEmailConfigMock.mockReturnValue(null); diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index 289c7490b..b072cb90b 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -55,6 +55,14 @@ function getTransporter(): { transporter: Transporter; from: string } | null { port: config.port, secure: config.secure, ...(config.auth ? { auth: config.auth } : {}), + // Bound every phase of the SMTP exchange. nodemailer's defaults (2 min + // connect, 30 s greeting, 10 min socket) would let a hung/blackholed relay + // pin a dispatch worker for minutes per send. These caps map a stall to the + // transient `SendOutcome` `classifySmtpError` already retries, matching the + // webhook sender's 5 s ethos. + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 20_000, }); cachedTransportKey = key; return { transporter: cachedTransporter, from: config.from };