aboutsummaryrefslogtreecommitdiff
path: root/deno/mail/mail.ts
diff options
context:
space:
mode:
Diffstat (limited to 'deno/mail/mail.ts')
-rw-r--r--deno/mail/mail.ts234
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);
+ }
+ }
+ }
+}