mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user