import { encodeBase64 } from "@std/encoding/base64"; import { getLogger } from "./logger.ts"; import { generateTimeStringForFileName } from "./util.ts"; export type MailDeliverStateNotSent = { kind: "not-sent" }; export type MailDeliverStateDelivered = { kind: "delivered"; deliverer: MailDeliverer; error?: Error; }; export type MailDeliverStateError = { kind: "error"; deliverer: MailDeliverer; error: Error; }; export type MailDeliverState = | MailDeliverStateNotSent | MailDeliverStateDelivered | MailDeliverStateError; class MailParseError extends Error { constructor( message: string, public readonly mail: Mail, public readonly lineNumber?: number, options?: ErrorOptions, ) { if (lineNumber != null) message += `(at line ${lineNumber})`; super(message, options); } } class MailDeliverError extends Error { constructor( message: string, public readonly mail: Mail, options?: ErrorOptions, ) { super(message, options); } } const eolNames = new Map([ ["\n", "\\n"], ["\r", "\\r"], ["\n\r", "\\n\\r"], ]); interface ParsedMail { sections: { header: string; body: string; }; /** * The empty line between headers and body. */ sep: string; eol: string; } export class Mail { messageId: string | null = null; awsMessageId: string | null = null; deliverState: MailDeliverState = { kind: "not-sent" }; constructor(public raw: string) {} setDelivered(deliverer: MailDeliverer, error?: Error) { this.deliverState = { "kind": "delivered", deliverer, error }; } throwDeliverError( deliverer: MailDeliverer, reason: string, cause?: unknown, ): never { const error = new MailDeliverError( `Failed to deliver mail to ${deliverer.destination}: ${reason}`, this, { cause }, ); this.deliverState = { "kind": "error", deliverer, error }; throw error; } toUtf8Bytes(): Uint8Array { const utf8Encoder = new TextEncoder(); return utf8Encoder.encode(this.raw); } toBase64(): string { return encodeBase64(this.raw); } simpleParse(): ParsedMail { const twoEolMatch = this.raw.match(/(\r\n|\n|\r)(\r\n|\n|\r)/); // "\r\n" is a false positive. if (twoEolMatch == null || twoEolMatch[0] === "\r\n") { throw new MailParseError( "No header/body section separator (2 successive EOLs) found.", this, ); } const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; if (eol !== sep) { getLogger().warn( `Different EOLs (${eolNames.get(eol)} and ${eolNames.get(sep)}) found.`, ); } return { sections: { header: this.raw.slice(0, twoEolMatch.index! + eol.length), body: this.raw.slice(twoEolMatch.index! + eol.length + sep.length), }, sep, eol, }; } simpleParseHeaders(): [key: string, value: string][] { const { sections } = this.simpleParse(); const headers: [string, string][] = []; let field: string | null = null; let lineNumber = 1; const handleField = () => { if (field == null) return; const sepPos = field.indexOf(":"); if (sepPos === -1) { throw new MailParseError( "No ':' in the header field.", this, lineNumber, ); } headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]); field = null; }; for (const line of sections.header.trimEnd().split(/\r?\n|\r/)) { if (line.match(/^\s/)) { if (field == null) { throw new MailParseError( "Header field starts with a space.", this, lineNumber, ); } field += line; } else { handleField(); field = line; } lineNumber += 1; } handleField(); return headers; } simpleGetDate(): Date | null { const headers = this.simpleParseHeaders(); for (const [key, value] of headers) { if (key.toLowerCase() === "date") { const date = new Date(value); if (isNaN(date.getTime())) { getLogger().warn(`Invalid date string (${value}) found in header.`); return null; } return date; } } return null; } simpleGetDateString(fallback: T): string | T { const date = this.simpleGetDate(); if (date == null) return fallback; return generateTimeStringForFileName(date, true); } // TODO: Add folding. appendHeaders(headers: [key: string, value: string][]) { const { sections, sep, eol } = this.simpleParse(); this.raw = sections.header + headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep + sections.body; } } type MailDeliverHook = (mail: Mail) => Promise; export abstract class MailDeliverer { preHooks: MailDeliverHook[] = []; postHooks: MailDeliverHook[] = []; constructor(public readonly destination: string) {} protected doPrepare(_mail: Mail): Promise { return Promise.resolve(); } protected abstract doDeliver(mail: Mail): Promise; protected doFinalize(_mail: Mail): Promise { return Promise.resolve(); } async deliverRaw(raw: string): Promise { const mail = new Mail(raw); await this.deliver(mail); } async deliver(mail: Mail): Promise { this.doPrepare(mail); for (const hook of this.preHooks) { await hook(mail); } await this.doDeliver(mail); if (mail.deliverState.kind === "not-sent") { mail.setDelivered(this); } for (const hook of this.postHooks) { await hook(mail); } await this.doFinalize(mail); } }