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("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"); }); });