/** * The one transactional message shape Ekklesia sends, and the boundary that validates it. * * **There is deliberately no `from` field.** The sender is host configuration * (`MailProviderConfig.fromAddress`), so no caller — not Core, not a future module — can choose who * a message appears to come from. Letting feature code pick a sender is how a platform acquires an * impersonation primitive and destroys its own domain reputation at the same time (ADR 0037). * * Also deliberately absent: CC, BCC, attachments, custom headers, and templates. Identity * verification, password reset, and invitations each need exactly one recipient and a short body. * Every field beyond that is surface a future caller could abuse, so it stays out until something * real needs it. */ export type MailErrorCode = | "MAIL_MESSAGE_INVALID" | "MAIL_NOT_CONFIGURED" | "MAIL_PROVIDER_UNAVAILABLE" | "MAIL_RECIPIENT_INVALID" | "MAIL_REJECTED" | "MAIL_SEND_FAILED"; const mailErrorMessages: Readonly> = Object.freeze({ MAIL_MESSAGE_INVALID: "The message is not one this contract accepts.", MAIL_NOT_CONFIGURED: "This installation has no outbound email provider configured.", MAIL_PROVIDER_UNAVAILABLE: "The outbound email service could not be reached.", MAIL_RECIPIENT_INVALID: "The recipient address is not a valid email address.", MAIL_REJECTED: "The outbound email service refused the message.", MAIL_SEND_FAILED: "The message could not be sent.", }); /** * A sanitized mail failure. * * Carries a code and a fixed message and **nothing else** — no SMTP host, no port, no username, no * password, no server greeting, no auth challenge, no provider response text, and no caught cause. * SMTP servers routinely echo the envelope and sometimes the credential state back in their reply * text, so forwarding a provider error verbatim is how a password reaches a log. It follows the same * discipline as `ModuleStorageError` and `StorageConfigError`. */ export class MailError extends Error { readonly code: MailErrorCode; constructor(code: MailErrorCode) { super(mailErrorMessages[code]); this.name = "MailError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** * Bounds, chosen to be generous for a transactional message and far too small for a newsletter. * * A verification email is a couple of kilobytes. These leave two orders of magnitude of headroom * while making it structurally impossible to hand the provider a megabyte, or to turn one call into * a mailing list. */ export const MAIL_MAX_RECIPIENTS = 10; export const MAIL_MAX_SUBJECT_LENGTH = 200; export const MAIL_MAX_TEXT_BYTES = 64 * 1024; export const MAIL_MAX_HTML_BYTES = 256 * 1024; /** RFC 5321 caps a path at 256 octets; the local part and domain limits sit inside that. */ export const MAIL_MAX_ADDRESS_LENGTH = 254; export const MAIL_MAX_DISPLAY_NAME_LENGTH = 128; export interface MailMessage { /** One address, or a small bounded list. */ readonly to: string | readonly string[]; readonly subject: string; /** At least one of `text` or `html` is required — see `validateMailMessage`. */ readonly text?: string; readonly html?: string; /** Where a human reply should go, when that is not the host's unattended sender. */ readonly replyTo?: string; } /** The message after validation: recipients normalized to a list, and every field proven safe to * hand a provider. A provider receives only this, never the caller's raw input. */ export interface ValidatedMailMessage { readonly to: readonly string[]; readonly subject: string; readonly text?: string; readonly html?: string; readonly replyTo?: string; } /** * Whether a string is free of everything that could break out of the field it is placed in. * * A mail header is line-oriented, so a carriage return or line feed in a subject, an address, or a * display name lets a caller append headers of its own — a `Bcc:` to somewhere else, a replaced * `From:`, or an entire second message body. This is the classic email header injection, and it is * why these characters are **refused** rather than stripped: silently rewriting a caller's input * means the value that was validated is not the value that gets sent, and the two can drift. * * Every other C0 control, DEL, and the C1 range go with them. None has a legitimate place in a * transactional subject line, and several are interpreted by intermediaries. */ function hasUnsafeCharacters(value: string): boolean { for (const character of value) { const codePoint = character.codePointAt(0); if (codePoint === undefined) { return true; } if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { return true; } } return false; } /** * A deliberately conservative address check. * * This is not an RFC 5322 parser and does not try to be: the point is to reject what is dangerous or * obviously wrong before it reaches the provider, and to let the provider be authoritative about * exotic-but-legal mailboxes. What it does guarantee is that the value is a single, bare * `local@domain` with no display name, no angle brackets, no comma, no semicolon, and no control * character — so it cannot smuggle a second recipient or a header break through a field the caller * believes holds one address. */ export function isValidMailAddress(value: unknown): value is string { if (typeof value !== "string") { return false; } // Checked **before** trimming, deliberately. Trimming first would silently strip a trailing // carriage return and accept the address, which contradicts the rule above: a control character in // an address is refused, never repaired. Surrounding ordinary whitespace is still tolerated, // because that is a copy-paste artefact rather than an injection attempt. if (hasUnsafeCharacters(value)) { return false; } const address = value.trim(); if (address.length === 0 || address.length > MAIL_MAX_ADDRESS_LENGTH) { return false; } // No grouping or list punctuation: one address means one address. if (/[,;<>"\\()[\]\s]/u.test(address)) { return false; } const at = address.lastIndexOf("@"); if (at <= 0 || at === address.length - 1) { return false; } const local = address.slice(0, at); const domain = address.slice(at + 1); if (local.length > 64 || local.includes("@")) { return false; } // A domain of at least two dot-separated labels, each alphanumeric with internal hyphens. This // rejects a bare hostname, which is legal SMTP but never a real transactional recipient and is a // common shape for a typo. return /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/iu.test( domain, ); } /** A display name that can be placed in a `From:` without escaping surprises. Quotes and backslashes * are refused rather than escaped, for the same reason control characters are. */ export function isValidMailDisplayName(value: unknown): value is string { return ( typeof value === "string" && value.trim().length > 0 && value.length <= MAIL_MAX_DISPLAY_NAME_LENGTH && !hasUnsafeCharacters(value) && !/["\\]/u.test(value) ); } function byteLength(value: string): number { return new TextEncoder().encode(value).length; } /** * Validates one message and normalizes its recipients. * * Recipient problems get their own code because they are the one failure an operator or a user can * usually act on — a mistyped address — while everything else here is a programming error in the * calling feature. */ export function validateMailMessage(message: MailMessage): ValidatedMailMessage { const recipients = (Array.isArray(message.to) ? message.to : [message.to]) as readonly unknown[]; if (recipients.length === 0 || recipients.length > MAIL_MAX_RECIPIENTS) { throw new MailError("MAIL_RECIPIENT_INVALID"); } const to: string[] = []; for (const recipient of recipients) { if (!isValidMailAddress(recipient)) { throw new MailError("MAIL_RECIPIENT_INVALID"); } to.push(recipient.trim()); } if (new Set(to.map((address) => address.toLowerCase())).size !== to.length) { throw new MailError("MAIL_RECIPIENT_INVALID"); } const subject = message.subject; if ( typeof subject !== "string" || subject.trim().length === 0 || subject.length > MAIL_MAX_SUBJECT_LENGTH || hasUnsafeCharacters(subject) ) { throw new MailError("MAIL_MESSAGE_INVALID"); } // At least one body. A message with neither is not a message, and silently sending an empty one // would be worse than refusing it. const hasText = typeof message.text === "string" && message.text.length > 0; const hasHtml = typeof message.html === "string" && message.html.length > 0; if (!hasText && !hasHtml) { throw new MailError("MAIL_MESSAGE_INVALID"); } if (hasText && byteLength(message.text as string) > MAIL_MAX_TEXT_BYTES) { throw new MailError("MAIL_MESSAGE_INVALID"); } if (hasHtml && byteLength(message.html as string) > MAIL_MAX_HTML_BYTES) { throw new MailError("MAIL_MESSAGE_INVALID"); } if (message.replyTo !== undefined && !isValidMailAddress(message.replyTo)) { throw new MailError("MAIL_MESSAGE_INVALID"); } return Object.freeze({ to: Object.freeze(to), subject, ...(hasText ? { text: message.text as string } : {}), ...(hasHtml ? { html: message.html as string } : {}), ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo.trim() }), }); }