diff options
Diffstat (limited to 'services/docker/mail-server/relay/mail.ts')
-rw-r--r-- | services/docker/mail-server/relay/mail.ts | 221 |
1 files changed, 153 insertions, 68 deletions
diff --git a/services/docker/mail-server/relay/mail.ts b/services/docker/mail-server/relay/mail.ts index 859ce63..08335ed 100644 --- a/services/docker/mail-server/relay/mail.ts +++ b/services/docker/mail-server/relay/mail.ts @@ -1,22 +1,9 @@ import { encodeBase64 } from "@std/encoding/base64"; +import { parse } from "@std/csv/parse"; +import emailAddresses from "email-addresses"; -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; +import { log, warn } from "./logger.ts"; +import { getConfigValue } from "./config.ts"; class MailParseError extends Error { constructor( @@ -30,22 +17,6 @@ class MailParseError extends Error { } } -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 { sections: { header: string; @@ -61,28 +32,10 @@ interface ParsedMail { export class Mail { date?: Date; messageId?: string; - deliverState: MailDeliverState = { kind: "not-sent" }; + deliverMessage?: string; 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 with ${deliverer.name}: ${reason}`, - this, - { cause }, - ); - this.deliverState = { "kind": "error", deliverer, error }; - throw error; - } - toUtf8Bytes(): Uint8Array { const utf8Encoder = new TextEncoder(); return utf8Encoder.encode(this.raw); @@ -93,9 +46,8 @@ export class Mail { } 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") { + const twoEolMatch = this.raw.match(/(\r?\n)(\r?\n)/); + if (twoEolMatch == null) { throw new MailParseError( "No header/body section separator (2 successive EOLs) found.", this, @@ -105,9 +57,7 @@ export class Mail { const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; if (eol !== sep) { - getLogger().warn( - `Different EOLs (${eolNames.get(eol)} and ${eolNames.get(sep)}) found.`, - ); + warn("Different EOLs (\\r\\n, \\n) found."); } return { @@ -171,7 +121,7 @@ export class Mail { if (key.toLowerCase() === "date") { const date = new Date(value); if (isNaN(date.getTime())) { - getLogger().warn(`Invalid date string (${value}) found in header.`); + warn(`Invalid date string (${value}) found in header.`); return invalidValue; } return date; @@ -180,6 +130,26 @@ export class Mail { return undefined; } + simpleParseReceipts( + options?: { domain?: string; headers?: string[] }, + ): Set<string> { + const domain = options?.domain; + const headers = options?.headers ?? ["to", "cc", "bcc", "x-original-to"]; + const receipts = new Set<string>(); + for (const [key, value] of this.simpleParseHeaders()) { + if (headers.includes(key.toLowerCase())) { + emailAddresses.parseAddressList(value)?.flatMap((a) => + a.type === "mailbox" ? a.address : a.addresses.map((a) => a.address) + )?.forEach((a) => { + if (domain == null || a.endsWith(domain)) { + receipts.add(a); + } + }); + } + } + return receipts; + } + // TODO: Add folding. appendHeaders(headers: [key: string, value: string][]) { const { sections, sep, eol } = this.simpleParse(); @@ -190,14 +160,59 @@ export class Mail { } } -export type MailDeliverHook = (mail: Mail) => Promise<void>; +export type MailDeliverResultKind = "done" | "fail" | "retry"; + +export interface MailDeliverReceiptResult { + kind: MailDeliverResultKind; + message: string; + cause?: unknown; +} + +export class MailDeliverResult { + readonly receipts: Map<string, MailDeliverReceiptResult> = new Map(); + + add( + receipt: string, + kind: MailDeliverResultKind, + message: string, + cause?: unknown, + ) { + this.receipts.set(receipt, { kind, message, cause }); + } + + set(receipt: string, result: MailDeliverReceiptResult) { + this.receipts.set(receipt, result); + } + + [Symbol.for("Deno.customInspect")]() { + return [ + ...this.receipts.entries().map(([receipt, result]) => + `${receipt}[${result.kind}]: ${result.message}` + ), + ].join("\n"); + } +} + +export class MailDeliverContext { + readonly receipts: Set<string> = new Set(); + readonly result: MailDeliverResult = new MailDeliverResult(); + + constructor(public mail: Mail) {} +} + +export interface MailDeliverHook { + callback(context: MailDeliverContext): Promise<void>; +} export abstract class MailDeliverer { abstract readonly name: string; preHooks: MailDeliverHook[] = []; postHooks: MailDeliverHook[] = []; - protected abstract doDeliver(mail: Mail): Promise<void>; + protected abstract doDeliver( + mail: Mail, + context: MailDeliverContext, + ): Promise<void>; async deliverRaw(rawMail: string): Promise<Mail> { const mail = new Mail(rawMail); @@ -205,19 +220,89 @@ export abstract class MailDeliverer { return mail; } - async deliver(mail: Mail): Promise<void> { + async deliver(mail: Mail): Promise<MailDeliverResult> { + log(`Begin to deliver mail via ${this.name}...`); + + const context = new MailDeliverContext(mail); + for (const hook of this.preHooks) { - await hook(mail); + await hook.callback(context); } - await this.doDeliver(mail); + await this.doDeliver(context.mail, context); - if (mail.deliverState.kind === "not-sent") { - mail.setDelivered(this); + for (const hook of this.postHooks) { + await hook.callback(context); } - for (const hook of this.postHooks) { - await hook(mail); + log("Deliver result:", context.result); + + if (context.result.receipts.values().some((r) => r.kind !== "done")) { + throw new Error("Mail failed to deliver."); + } + + return context.result; + } +} + +export class ReceiptsFromHeadersHook implements MailDeliverHook { + callback(context: MailDeliverContext) { + if (context.receipts.size !== 0) { + warn("Receipts are already filled. Won't set them with ones in headers."); + } else { + context.mail.simpleParseReceipts({ + domain: getConfigValue("mailDomain"), + }).forEach((r) => context.receipts.add(r)); + + if (context.receipts.size === 0) { + warn("No receipts found from mail headers."); + } + } + return Promise.resolve(); + } +} + +export class FallbackReceiptsHook implements MailDeliverHook { + constructor(public fallback: Set<string> = new Set()) {} + + callback(context: MailDeliverContext) { + if (context.receipts.size === 0) { + log(`No receipts, fill with fallback ${[...this.fallback].join(" ")}.`); + this.fallback.forEach((a) => context.receipts.add(a)); + } + return Promise.resolve(); + } +} + +export class AliasFileMailHook implements MailDeliverHook { + #aliasFile; + + constructor(aliasFile: string) { + this.#aliasFile = aliasFile; + } + + async #parseAliasFile(): Promise<Map<string, string>> { + const result = new Map(); + if ((await Deno.stat(this.#aliasFile)).isFile) { + log(`Found receipts 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(); + for (const receipt of [...context.receipts]) { + const realReceipt = aliases.get(receipt); + if (realReceipt != null) { + log(`Receipt alias resolved: ${receipt} => ${realReceipt}.`); + context.receipts.delete(receipt); + context.receipts.add(realReceipt); + } } } } |