import { encodeBase64 } from "@std/encoding/base64"; import { parse } from "@std/csv/parse"; import emailAddresses from "email-addresses"; import { Logger } from "@crupest/base/log"; class MailSimpleParseError extends Error {} class MailSimpleParsedHeaders { #logger; constructor( logger: Logger | undefined, public fields: [key: string, value: string][], ) { this.#logger = logger; } getFirst(fieldKey: string): string | undefined { for (const [key, value] of this.fields) { if (key.toLowerCase() === fieldKey.toLowerCase()) return value; } return undefined; } messageId(): string | undefined { const messageIdField = this.getFirst("message-id"); if (messageIdField == null) return undefined; const match = messageIdField.match(/\<(.*?)\>/); if (match != null) { return match[1]; } else { this.#logger?.warn("Invalid message-id header of mail: ", messageIdField); return undefined; } } date(invalidToUndefined: boolean = true): Date | undefined { const dateField = this.getFirst("date"); if (dateField == null) return undefined; const date = new Date(dateField); if (invalidToUndefined && isNaN(date.getTime())) { this.#logger?.warn(`Invalid date string (${dateField}) found in header.`); return undefined; } return date; } recipients(options?: { domain?: string; headers?: string[] }): Set { const domain = options?.domain; const headers = options?.headers ?? ["to", "cc", "bcc", "x-original-to"]; const recipients = new Set(); for (const [key, value] of this.fields) { if (headers.includes(key.toLowerCase())) { emailAddresses .parseAddressList(value) ?.flatMap((a) => (a.type === "mailbox" ? a : a.addresses)) ?.forEach(({ address }) => { if (domain == null || address.endsWith(domain)) { recipients.add(address); } }); } } return recipients; } } class MailSimpleParsedSections { header: string; body: string; eol: string; sep: string; #logger; constructor(logger: Logger | undefined, raw: string) { this.#logger = logger; const twoEolMatch = raw.match(/(\r?\n)(\r?\n)/); if (twoEolMatch == null) { throw new MailSimpleParseError( "No header/body section separator (2 successive EOLs) found.", ); } const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; if (eol !== sep) { logger?.warn("Different EOLs (\\r\\n, \\n) found."); } this.header = raw.slice(0, twoEolMatch.index!); this.body = raw.slice(twoEolMatch.index! + eol.length + sep.length); this.eol = eol; this.sep = sep; } headers(): MailSimpleParsedHeaders { const headers = [] as [key: string, value: string][]; let field: string | null = null; let lineNumber = 1; const handleField = () => { if (field == null) return; const sepPos = field.indexOf(":"); if (sepPos === -1) { throw new MailSimpleParseError(`No ':' in the header line: ${field}`); } headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]); field = null; }; for (const line of this.header.trimEnd().split(/\r?\n|\r/)) { if (line.match(/^\s/)) { if (field == null) { throw new MailSimpleParseError("Header section starts with a space."); } field += line; } else { handleField(); field = line; } lineNumber += 1; } handleField(); return new MailSimpleParsedHeaders(this.#logger, headers); } } export class Mail { constructor(public raw: string) {} toUtf8Bytes(): Uint8Array { const utf8Encoder = new TextEncoder(); return utf8Encoder.encode(this.raw); } toBase64(): string { return encodeBase64(this.raw); } startSimpleParse(logger?: Logger) { return { sections: () => new MailSimpleParsedSections(logger, this.raw) }; } simpleFindAllAddresses(): string[] { const re = /,?\?,?/gi; return [...this.raw.matchAll(re)].map((m) => m[1]); } } export type MailDeliverResultKind = "done" | "fail"; export interface MailDeliverRecipientResult { kind: MailDeliverResultKind; message: string; cause?: unknown; } export class MailDeliverResult { message: string = ""; recipients: Map = new Map(); constructor(public mail: Mail) {} hasError(): boolean { return ( this.recipients.size === 0 || this.recipients.values().some((r) => r.kind !== "done") ); } [Symbol.for("Deno.customInspect")]() { return [ `message: ${this.message}`, ...this.recipients .entries() .map( ([recipient, result]) => `${recipient} [${result.kind}]: ${result.message}`, ), ].join("\n"); } } export class MailDeliverContext { readonly recipients: Set = new Set(); readonly result; constructor( public readonly logger: Logger, public mail: Mail, ) { this.result = new MailDeliverResult(this.mail); } } export interface MailDeliverHook { callback(context: MailDeliverContext): Promise; } export abstract class MailDeliverer { abstract readonly name: string; preHooks: MailDeliverHook[] = []; postHooks: MailDeliverHook[] = []; constructor(protected readonly logger: Logger) {} protected abstract doDeliver( mail: Mail, context: MailDeliverContext, ): Promise; async deliverRaw(rawMail: string) { return await this.deliver({ mail: new Mail(rawMail) }); } async deliver(options: { mail: Mail; recipients?: string[]; }): Promise { this.logger.info(`Begin to deliver mail via ${this.name}...`); const context = new MailDeliverContext(this.logger, options.mail); options.recipients?.forEach((r) => context.recipients.add(r)); for (const hook of this.preHooks) { await hook.callback(context); } await this.doDeliver(context.mail, context); for (const hook of this.postHooks) { await hook.callback(context); } context.logger.info("Deliver result:"); context.logger.info(context.result); if (context.result.hasError()) { throw new Error("Mail failed to deliver."); } return context.result; } } export abstract class SyncMailDeliverer extends MailDeliverer { #last: Promise = Promise.resolve(); override async deliver(options: { mail: Mail; recipients?: string[]; }): Promise { this.logger.info( "The mail deliverer is sync. Wait for last delivering done...", ); await this.#last; const result = super.deliver(options); this.#last = result.then( () => {}, () => {}, ); return result; } } export class RecipientFromHeadersHook implements MailDeliverHook { constructor(public mailDomain: string) {} callback(context: MailDeliverContext) { if (context.recipients.size !== 0) { context.logger.warn( "Recipients are already filled. Won't set them with ones in headers.", ); } else { context.mail .startSimpleParse(context.logger) .sections() .headers() .recipients({ domain: this.mailDomain, }) .forEach((r) => context.recipients.add(r)); context.logger.info( "Recipients found from mail headers: ", [...context.recipients].join(" "), ); } return Promise.resolve(); } } export class FallbackRecipientHook implements MailDeliverHook { constructor(public fallback: Set = new Set()) {} callback(context: MailDeliverContext) { if (context.recipients.size === 0) { context.logger.info( "No recipients, fill with fallback: ", [...this.fallback].join(" "), ); this.fallback.forEach((a) => context.recipients.add(a)); } return Promise.resolve(); } } export class AliasRecipientMailHook implements MailDeliverHook { #aliasFile; constructor(aliasFile: string) { this.#aliasFile = aliasFile; } async #parseAliasFile(logger: Logger): Promise> { const result = new Map(); if ((await Deno.stat(this.#aliasFile)).isFile) { logger.info(`Found recipients alias file: ${this.#aliasFile}.`); const text = await Deno.readTextFile(this.#aliasFile); const csv = parse(text); for (const [real, ...aliases] of csv) { aliases.forEach((a) => result.set(a, real)); } } return result; } async callback(context: MailDeliverContext) { const aliases = await this.#parseAliasFile(context.logger); for (const recipient of [...context.recipients]) { const realRecipients = aliases.get(recipient); if (realRecipients != null) { context.logger.info( `Recipient alias resolved: ${recipient} => ${realRecipients}.`, ); context.recipients.delete(recipient); context.recipients.add(realRecipients); } } } }