/** * Mail delivery layer for the notification system's `mail` channel. * * A `MailDriver` is the transport (log / SMTP / Resend). It receives a fully-resolved * `MailPayload` — the wire shape of one email — and delivers it. The fluent * {@link MailMessage} a notification returns from `toMail()` is rendered into this * payload by `MailChannel`, so drivers never deal with templates or notifiables. */ export interface MailAddress { address: string; name?: string; } export type AddressInput = string | MailAddress; /** Normalise a string or object address to a `MailAddress`. */ export function resolveAddress(input: AddressInput): MailAddress { return typeof input === "string" ? { address: input } : input; } /** A file carried alongside the message body. */ export interface MailAttachment { /** Name the recipient sees. */ filename: string; /** File bytes, or a string for text content. */ content: string | Uint8Array; /** MIME type. Default: `application/octet-stream`. */ contentType?: string; /** * Reference the part from the HTML body instead of listing it as a download. * Set `cid` too and use ``. */ inline?: boolean; /** Content-ID for an inline part, without the angle brackets. */ cid?: string; } /** * Header names a driver builds itself, which a custom header may not set. * * Two reasons, and both are about a message that arrives wrong rather than one * that fails to send. `Content-Type`, `MIME-Version` and * `Content-Transfer-Encoding` describe the body a driver is assembling — a second * copy makes the structure ambiguous and clients disagree about which to believe. * The rest already have a dedicated method on {@link MailMessage}; setting one * here would duplicate the header rather than replace it, because the driver has * no way to know which was meant. * * Compared case-insensitively — header names are not case-sensitive, so * `content-type` has to be refused as readily as `Content-Type`. */ export const RESERVED_MAIL_HEADERS: readonly string[] = [ "bcc", "cc", "content-transfer-encoding", "content-type", "date", "from", "message-id", "mime-version", "reply-to", "subject", "to", ]; /** * Custom headers, sanitised and checked against {@link RESERVED_MAIL_HEADERS}. * * @param headers - Header names to values, as written by the caller. * @returns The same headers with CR and LF removed from every value. * @throws When a name is reserved, or a name is not a legal header token. */ export function resolveHeaders(headers: Record): Record { const out: Record = {}; for (const [name, value] of Object.entries(headers)) { const trimmed = name.trim(); if (RESERVED_MAIL_HEADERS.includes(trimmed.toLowerCase())) { throw new Error( `[Zerotal/notifications] "${trimmed}" is set by the mail driver and cannot be ` + `passed as a custom header — it would be sent twice rather than replaced. ` + `Use the MailMessage method for it (.subject(), .from(), .to(), .cc(), .bcc(), ` + `.replyTo()) where there is one.`, ); } // RFC 5322 field names are printable ASCII except colon. A name carrying a // colon, a space or a newline does not become a header — it becomes a way to // write the rest of the message. if (!/^[!-9;-~]+$/.test(trimmed)) { throw new Error( `[Zerotal/notifications] "${trimmed}" is not a valid header name — a header name is ` + `printable ASCII with no spaces, colons or line breaks.`, ); } // CR/LF in a value ends the header and lets the remainder be read as further // headers. Stripped rather than refused: unlike the name, a value is often // built from content, and folding it to one line keeps the message intact. out[trimmed] = value.replace(/[\r\n]+/g, " ").trim(); } return out; } /** The resolved, ready-to-send shape of a single email. */ export interface MailPayload { to: MailAddress[]; from: MailAddress; subject: string; text?: string; html?: string; cc?: MailAddress[]; bcc?: MailAddress[]; replyTo?: MailAddress; attachments?: MailAttachment[]; /** * Extra headers to put on the message, beyond the ones the driver builds. * * The reason this exists is `List-Unsubscribe`: Gmail and Yahoo draw their * native unsubscribe control beside the sender from that header, and there is * no way to ask for it other than sending it. A footer link is a different * thing in a different place, and bulk senders are required to send the header. * * Names in {@link RESERVED_MAIL_HEADERS} are refused — see there for why. * * @example * ```ts * new MailMessage() * .subject("Your weekly digest") * .header("List-Unsubscribe", ``) * .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"); * ``` */ headers?: Record; } export interface MailDriver { send(message: MailPayload): Promise; }