diff options
Diffstat (limited to 'deno/mail/mail.ts')
-rw-r--r-- | deno/mail/mail.ts | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/deno/mail/mail.ts b/deno/mail/mail.ts new file mode 100644 index 0000000..9cc591c --- /dev/null +++ b/deno/mail/mail.ts @@ -0,0 +1,234 @@ +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 = /,?\<?([a-z0-9_'+\-\.]+\@[a-z0-9_'+\-\.]+)\>?,?/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<string, MailDeliverRecipientResult>(); + 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 } = result; + lines.push(`${prefix} (${name}): ${kind} ${message}`); + } + return lines.join("\n"); + } +} + +export class MailDeliverContext { + readonly recipients: Set<string> = new Set(); + readonly result; + + constructor(public logTag: string, public mail: Mail) { + this.result = new MailDeliverResult(this.mail); + } +} + +export interface MailDeliverHook { + callback(context: MailDeliverContext): Promise<void>; +} + +export abstract class MailDeliverer { + #counter = 1; + #last?: Promise<void>; + + abstract name: string; + preHooks: MailDeliverHook[] = []; + postHooks: MailDeliverHook[] = []; + + constructor(public sync: boolean) {} + + protected abstract doDeliver( + mail: Mail, + context: MailDeliverContext, + ): Promise<void>; + + 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<MailDeliverResult> { + 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<string> = 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<Map<string, string>> { + 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); + } + } + } +} |