import { describe, expect, it } from "vitest"; import { createMailProviderConfig, describeMailProvider, MailConfigError, } from "../src/mail/config.js"; import { MAIL_MAX_HTML_BYTES, MAIL_MAX_RECIPIENTS, MAIL_MAX_SUBJECT_LENGTH, MAIL_MAX_TEXT_BYTES, MailError, validateMailMessage, } from "../src/mail/message.js"; /** * The mail contract's boundary, with no provider and no network. * * Most of these are negative: the contract's job is to make an impossible message impossible to * construct, so what it *refuses* is the interesting part (ADR 0037). */ const password = "sup3r-secret-relay-password"; const validConfig = { fromAddress: "noreply@example.org", fromName: "Ekklesia", host: "smtp.example.org", security: "starttls", }; describe("host mail configuration", () => { it("treats an absent host as an installation that sends no email", () => { // The whole platform must still start. Nothing sends mail yet, so this is a complete deployment. expect(createMailProviderConfig(undefined)).toBeUndefined(); expect(createMailProviderConfig({})).toBeUndefined(); expect(createMailProviderConfig({ host: " " })).toBeUndefined(); }); it("accepts a relay with no credentials, which is how Google Workspace is usually deployed", () => { const config = createMailProviderConfig(validConfig); // Requiring a username and password — as most SMTP examples do — would make the intended // production arrangement, a relay authorized by source IP, unconfigurable. expect(config).toEqual({ fromAddress: "noreply@example.org", fromName: "Ekklesia", host: "smtp.example.org", port: 587, security: "starttls", }); expect(config?.username).toBeUndefined(); expect(config?.password).toBeUndefined(); }); it("defaults the port per security mode, and defaults the mode to an encrypted one", () => { expect(createMailProviderConfig({ ...validConfig, security: undefined })?.security).toBe( "starttls", ); expect(createMailProviderConfig({ ...validConfig, security: "implicit-tls" })?.port).toBe(465); expect(createMailProviderConfig({ ...validConfig, security: "starttls" })?.port).toBe(587); expect(createMailProviderConfig({ ...validConfig, port: "2525" })?.port).toBe(2525); }); it("refuses half a credential pair in either direction", () => { expect(() => createMailProviderConfig({ ...validConfig, username: "relay@example.org" }), ).toThrow(expect.objectContaining({ code: "MAIL_USERNAME_WITHOUT_PASSWORD" })); expect(() => createMailProviderConfig({ ...validConfig, password })).toThrow( expect.objectContaining({ code: "MAIL_PASSWORD_WITHOUT_USERNAME" }), ); // Both together is the authenticated case and is accepted. expect( createMailProviderConfig({ ...validConfig, password, username: "relay@example.org" }) ?.username, ).toBe("relay@example.org"); }); it.each([ ["a scheme", "smtp://smtp.example.org"], ["a port", "smtp.example.org:587"], ["credentials", "user:pass@smtp.example.org"], ["a path", "smtp.example.org/relay"], ["a space", "smtp example org"], ["nothing but dots", "..."], ])("refuses an SMTP host containing %s", (_label, host) => { // A host is a hostname, never a connection string — a connection string is how a password ends // up in a log. expect(() => createMailProviderConfig({ ...validConfig, host })).toThrow( expect.objectContaining({ code: "MAIL_HOST_INVALID" }), ); }); it.each([ ["0"], ["65536"], ["-1"], ["587.5"], ["not-a-port"], ])("refuses the malformed port %s", (port) => { expect(() => createMailProviderConfig({ ...validConfig, port })).toThrow( expect.objectContaining({ code: "MAIL_PORT_INVALID" }), ); }); it("treats an empty port as unset rather than malformed", () => { expect(createMailProviderConfig({ ...validConfig, port: "" })?.port).toBe(587); }); it("refuses an unknown security mode rather than guessing one", () => { for (const security of ["tls", "ssl", "none", "STARTTLS", "true"]) { expect(() => createMailProviderConfig({ ...validConfig, security }), security).toThrow( expect.objectContaining({ code: "MAIL_SECURITY_INVALID" }), ); } }); it("refuses plaintext SMTP in production but allows it in development", () => { expect(() => createMailProviderConfig( { ...validConfig, security: "plaintext" }, { environment: "production" }, ), ).toThrow(expect.objectContaining({ code: "MAIL_SECURITY_INSECURE_IN_PRODUCTION" })); expect( createMailProviderConfig( { ...validConfig, security: "plaintext" }, { environment: "development" }, )?.security, ).toBe("plaintext"); }); it("requires a valid sender address and refuses an injectable display name", () => { expect(() => createMailProviderConfig({ ...validConfig, fromAddress: undefined })).toThrow( expect.objectContaining({ code: "MAIL_FROM_ADDRESS_MISSING" }), ); for (const fromAddress of [ "not-an-address", "a@b", "a@@b.org", "a b@example.org", "", ]) { expect(() => createMailProviderConfig({ ...validConfig, fromAddress }), fromAddress).toThrow( expect.objectContaining({ code: "MAIL_FROM_ADDRESS_INVALID" }), ); } for (const fromName of ['Ekklesia"', "Ekklesia\\", "Ekklesia\r\nBcc: attacker@example.org"]) { expect(() => createMailProviderConfig({ ...validConfig, fromName })).toThrow( expect.objectContaining({ code: "MAIL_FROM_NAME_INVALID" }), ); } }); it("never puts a credential into a configuration error or a description", () => { const configured = createMailProviderConfig({ ...validConfig, password, username: "relay@example.org", }); if (!configured) { throw new Error("The mail configuration fixture produced no configuration."); } const described = describeMailProvider(configured); // An operator sees where mail goes and whether it authenticates — never with what. expect(described).toContain("smtp.example.org:587"); expect(described).toContain("starttls"); expect(described).toContain("noreply@example.org"); expect(described).toContain("authenticated"); expect(described).not.toContain(password); expect(described).not.toContain("relay@example.org"); const unauthenticated = createMailProviderConfig(validConfig); if (!unauthenticated) { throw new Error("The mail configuration fixture produced no configuration."); } expect(describeMailProvider(unauthenticated)).toContain("unauthenticated relay"); let caught: unknown; try { createMailProviderConfig({ ...validConfig, host: "smtp://bad", password, username: "u@e.org", }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(MailConfigError); const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`; expect(rendered).not.toContain(password); expect(rendered).not.toContain("smtp://bad"); }); }); describe("SMTP client identity (HELO name)", () => { /** * A real deployment defect, and a generic one. * * An SMTP client must greet the server with its own identity. Nodemailer derives that from the * machine hostname and falls back to the address literal `[127.0.0.1]` when the hostname is not * fully qualified — the normal state of a container. Some relays close the connection on that * greeting with a `421` before any message is offered, which reaches a caller as an * indistinguishable `MAIL_PROVIDER_UNAVAILABLE`. The deployment can now say what to greet as. */ it("omits the setting entirely when the deployment configures none", () => { const config = createMailProviderConfig(validConfig); // Absent means Nodemailer's own derivation, unchanged. The key is not present at all rather // than present-and-undefined, so nothing downstream can mistake it for a configured empty name. expect(config).not.toHaveProperty("heloName"); expect(createMailProviderConfig({ ...validConfig, heloName: "" })).not.toHaveProperty( "heloName", ); expect(createMailProviderConfig({ ...validConfig, heloName: undefined })).not.toHaveProperty( "heloName", ); }); it.each([ ["a two-label domain", "example.org"], ["a subdomain", "mail.example.org"], ["a deep subdomain", "smtp.relay.mail.example.org"], ["hyphens inside labels", "out-bound.example-church.org"], ["digits", "mx1.example2.org"], ["mixed case", "Mail.Example.ORG"], ])("accepts %s", (_label, heloName) => { expect(createMailProviderConfig({ ...validConfig, heloName })).toMatchObject({ heloName }); }); it.each([ ["a scheme", "smtp://example.org"], ["a port", "example.org:587"], ["a path", "example.org/relay"], ["a space", "example org"], ["a leading space", " example.org"], ["a trailing space", "example.org "], ["a carriage return", "example.org\r"], ["a line feed", "example.org\nEHLO evil.example.net"], ["a tab", "example.org\t"], ["a comma", "example.org,evil.example.net"], ["an unqualified single label", "localhost"], ["a machine name with no domain", "ekklesia-host"], ["a bracketed address literal", "[127.0.0.1]"], ["a bare IPv4 address", "192.0.2.1"], ["a trailing dot", "example.org."], ["a leading dot", ".example.org"], ["consecutive dots", "example..org"], ["a leading hyphen in a label", "-example.org"], ["a trailing hyphen in a label", "example-.org"], ["an email address", "noreply@example.org"], ["prose", "our church mail server"], ["nothing but dots", "..."], ["an over-long name", `${"a".repeat(250)}.example.org`], ])("refuses %s", (_label, heloName) => { expect(() => createMailProviderConfig({ ...validConfig, heloName })).toThrow( expect.objectContaining({ code: "MAIL_HELO_NAME_INVALID" }), ); }); it("refuses an unqualified name, because that is the input the fallback already rejects", () => { // Configuring a single label would reproduce the very failure this setting exists to fix: // Nodemailer discards a non-FQDN hostname and substitutes the address literal. expect(() => createMailProviderConfig({ ...validConfig, heloName: "ekklesia" })).toThrow( MailConfigError, ); }); it("never carries the offending value in the error", () => { try { createMailProviderConfig({ ...validConfig, heloName: "smtp://secret-internal-name:2525" }); throw new Error("expected a refusal"); } catch (error) { const serialized = `${(error as Error).message} ${(error as Error).stack ?? ""}`; expect(serialized).not.toContain("secret-internal-name"); expect(serialized).not.toContain("2525"); } }); it("is described to an operator, because it is infrastructure rather than a credential", () => { const described = describeMailProvider( createMailProviderConfig({ ...validConfig, heloName: "mail.example.org", password, username: "relay@example.org", }) as Parameters[0], ); // It is announced in the clear on every connection, so printing it leaks nothing — and it is // exactly what an operator needs when a relay has refused the greeting. expect(described).toContain("HELO mail.example.org"); expect(described).not.toContain(password); expect(described).not.toContain("relay@example.org"); }); it("says nothing about a HELO name when none is configured", () => { const described = describeMailProvider( createMailProviderConfig(validConfig) as Parameters[0], ); expect(described).not.toContain("HELO"); }); }); describe("transactional message validation", () => { const message = { subject: "Verify your email address", text: "Open the link to continue.", to: "person@example.org", }; it("accepts one recipient and normalizes it to a list", () => { expect(validateMailMessage(message)).toEqual({ subject: "Verify your email address", text: "Open the link to continue.", to: ["person@example.org"], }); }); it("has no sender field at all, so no caller can choose one", () => { const validated = validateMailMessage({ ...message, html: "

Hi

", replyTo: "a@b.org" }); // The load-bearing structural fact: the sender comes from host configuration and there is // nowhere in a message to put one. expect(Object.keys(validated).sort()).toEqual(["html", "replyTo", "subject", "text", "to"]); expect(validated).not.toHaveProperty("from"); }); it.each([ ["a display name", "Person "], ["a second address", "a@example.org, b@example.org"], ["a semicolon list", "a@example.org;b@example.org"], ["a line feed", "person@example.org\nBcc: attacker@example.org"], ["a carriage return", "person@example.org\r"], ["no domain dot", "person@localhost"], ["no local part", "@example.org"], ["nothing", ""], ])("refuses a recipient containing %s", (_label, to) => { expect(() => validateMailMessage({ ...message, to })).toThrow( expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }), ); }); it("bounds the recipient list and refuses duplicates", () => { const many = Array.from( { length: MAIL_MAX_RECIPIENTS + 1 }, (_value, index) => `person${index}@example.org`, ); expect(() => validateMailMessage({ ...message, to: many })).toThrow( expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }), ); expect(() => validateMailMessage({ ...message, to: [] })).toThrow( expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }), ); // Case-insensitively: the same mailbox twice would send one person two copies. expect(() => validateMailMessage({ ...message, to: ["a@example.org", "A@Example.org"] }), ).toThrow(expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" })); expect( validateMailMessage({ ...message, to: many.slice(0, MAIL_MAX_RECIPIENTS) }).to, ).toHaveLength(MAIL_MAX_RECIPIENTS); }); it("refuses a subject carrying a header break or no content", () => { for (const subject of [ "Reset\r\nBcc: attacker@example.org", "Reset\nX-Injected: 1", " ", "", ]) { expect(() => validateMailMessage({ ...message, subject })).toThrow( expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }), ); } }); it("bounds the subject and both bodies", () => { expect(() => validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH + 1) }), ).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" })); expect(() => validateMailMessage({ ...message, text: "x".repeat(MAIL_MAX_TEXT_BYTES + 1) }), ).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" })); expect(() => validateMailMessage({ html: "x".repeat(MAIL_MAX_HTML_BYTES + 1), subject: message.subject, to: message.to, }), ).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" })); // Exactly at the bound is accepted, so the limit is the limit. expect( validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH) }).subject, ).toHaveLength(MAIL_MAX_SUBJECT_LENGTH); }); it("requires at least one body, and accepts either alone", () => { expect(() => validateMailMessage({ subject: message.subject, to: message.to })).toThrow( expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }), ); expect(() => validateMailMessage({ ...message, text: "" })).toThrow( expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }), ); expect( validateMailMessage({ html: "

Hi

", subject: message.subject, to: message.to }).html, ).toBe("

Hi

"); }); it("refuses an injectable reply-to", () => { expect(() => validateMailMessage({ ...message, replyTo: "office@example.org\r\nBcc: a@b.org", }), ).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" })); expect(validateMailMessage({ ...message, replyTo: "office@example.org" }).replyTo).toBe( "office@example.org", ); }); it("never echoes the message into the error", () => { let caught: unknown; try { validateMailMessage({ ...message, subject: `secret-${password}` + "\r\nBcc: a@b.org", }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(MailError); const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`; expect(rendered).not.toContain(password); expect(rendered).not.toContain("person@example.org"); }); });