import { encodeBase64 } from "@std/encoding/base64"; import { getLogger } from "./logger.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, options?: ErrorOptions, ) { 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 { headerStr: string; bodyStr: string; sepStr: 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 separator (2 successive EOLs) found. Cannot append headers.", this, ); } const [eol, sepStr] = [twoEolMatch[1], twoEolMatch[2]]; if (eol !== sepStr) { getLogger().warn( `Different EOLs (${eolNames.get(eol)} \ and ${eolNames.get(sepStr)}) found in mail.`, ); } return { headerStr: this.raw.slice(0, twoEolMatch.index! + eol.length), bodyStr: this.raw.slice(twoEolMatch.index! + eol.length + sepStr.length), sepStr, eol, }; } simpleParseHeaders(): [key: string, value: string][] { const { headerStr } = this.simpleParse(); const rawLines = headerStr.split(/\r\n|\n|\r/); const headerLines = []; for (const l of rawLines) { } } appendHeaders(headers: [key: string, value: string][]) { const { headerStr, bodyStr, sepStr, eol } = this.simpleParse(); const newHeaderStr = headerStr + headers.map(([k, v]) => `${k}: ${v}${eol}`).join(""); this.raw = newHeaderStr + sepStr + bodyStr; } } 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); } }