fix(notifications): bound the SMTP transport with explicit timeouts

The email sender built its nodemailer transport without connection,
greeting, or socket timeouts, so nodemailer's defaults applied — up to a
10-minute socket timeout. A mail server that accepts the TCP connection
then stalls (firewalled relay, overloaded Postfix, blackholed smarthost)
could hold a dispatch worker for minutes per notification, while every
other egress in the cascade is bounded. Pin connection/greeting at 10s
and socket at 20s so a stall maps to the transient send outcome the SMTP
classifier already retries, matching the webhook sender's ethos.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:27:49 +02:00
1 parent a27247f93c
commit b87ded090c
2 files changed
+30 -4

No files matched your search

@@ -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);
+8
View File
@@ -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 };