import { encodeBase64 } from "@std/encoding/base64"; import { parse } from "@std/csv/parse"; import { simpleParseMail } from "./mail-parsing.ts"; export class Mail { #raw; #parsed; constructor(raw: string) { this.#raw = raw; this.#parsed = simpleParseMail(raw); } get raw() { return this.#raw; } set raw(value) { this.#raw = value; this.#parsed = simpleParseMail(value); } get parsed() { return this.#parsed; } toUtf8Bytes(): Uint8Array { const utf8Encoder = new TextEncoder(); return utf8Encoder.encode(this.raw); } toBase64(): string { return encodeBase64(this.raw); } simpleFindAllAddresses(): string[] { const re = /,?\?,?/gi; return [...this.raw.matchAll(re)].map((m) => m[1]); } } export interface MailDeliverRecipientResult { kind: "success" | "failure"; message?: string; cause?: unknown; } export class MailDeliverResult { message?: string; smtpMessage?: string; recipients = new Map(); constructor(public mail: Mail) {} get hasFailure() { return this.recipients.values().some((v) => v.kind !== "success"); } generateLogMessage(prefix: string) { const lines = []; if (this.message != null) lines.push(`${prefix} message: ${this.message}`); if (this.smtpMessage != null) { lines.push(`${prefix} smtpMessage: ${this.smtpMessage}`); } for (const [name, result] of this.recipients.entries()) { const { kind, message, cause } = result; lines.push(`${prefix} (${name}): ${kind} ${message} ${cause}`); } return lines.join("\n"); } } export class MailDeliverContext { readonly recipients: Set = new Set(); readonly result; constructor(public logTag: string, public mail: Mail) { this.result = new MailDeliverResult(this.mail); } } export interface MailDeliverHook { callback(context: MailDeliverContext): Promise; } export abstract class MailDeliverer { #counter = 1; #last?: Promise; abstract name: string; preHooks: MailDeliverHook[] = []; postHooks: MailDeliverHook[] = []; constructor(public sync: boolean) {} protected abstract doDeliver( mail: Mail, context: MailDeliverContext, ): Promise; async deliverRaw(rawMail: string) { return await this.deliver({ mail: new Mail(rawMail) }); } async #deliverCore(context: MailDeliverContext) { 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); } } async deliver(options: { mail: Mail; recipients?: string[]; logTag?: string; }): Promise { const logTag = options.logTag ?? `[${this.name} ${this.#counter}]`; this.#counter++; if (this.#last != null) { console.info(logTag, "Wait for last delivering done..."); await this.#last; } const context = new MailDeliverContext( logTag, options.mail, ); options.recipients?.forEach((r) => context.recipients.add(r)); console.info(context.logTag, "Begin to deliver mail..."); const deliverPromise = this.#deliverCore(context); if (this.sync) { this.#last = deliverPromise.then(() => {}, () => {}); } await deliverPromise; this.#last = undefined; console.info(context.logTag, "Deliver result:"); console.info(context.result.generateLogMessage(context.logTag)); if (context.result.hasFailure) { throw new Error("Failed to deliver to some recipients."); } return context.result; } } export class RecipientFromHeadersHook implements MailDeliverHook { constructor(public mailDomain: string) {} callback(context: MailDeliverContext) { if (context.recipients.size !== 0) { console.warn( context.logTag, "Recipients are already filled, skip inferring from headers.", ); } else { [...context.mail.parsed.recipients].filter((r) => r.endsWith("@" + this.mailDomain) ).forEach((r) => context.recipients.add(r)); console.info( context.logTag, "Use recipients inferred 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) { console.info( context.logTag, "Use fallback recipients:" + [...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(logTag: string): Promise> { const result = new Map(); if ((await Deno.stat(this.#aliasFile)).isFile) { const text = await Deno.readTextFile(this.#aliasFile); const csv = parse(text); for (const [real, ...aliases] of csv) { aliases.forEach((a) => result.set(a, real)); } } else { console.warn( logTag, `Recipient alias file ${this.#aliasFile} is not found.`, ); } return result; } async callback(context: MailDeliverContext) { const aliases = await this.#parseAliasFile(context.logTag); for (const recipient of [...context.recipients]) { const realRecipients = aliases.get(recipient); if (realRecipients != null) { console.info( context.logTag, `Recipient alias resolved: ${recipient} => ${realRecipients}.`, ); context.recipients.delete(recipient); context.recipients.add(realRecipients); } } } }