diff options
Diffstat (limited to 'services/docker/mail-server/relay/mail.ts')
-rw-r--r-- | services/docker/mail-server/relay/mail.ts | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/services/docker/mail-server/relay/mail.ts b/services/docker/mail-server/relay/mail.ts new file mode 100644 index 0000000..08335ed --- /dev/null +++ b/services/docker/mail-server/relay/mail.ts @@ -0,0 +1,308 @@ +import { encodeBase64 } from "@std/encoding/base64"; +import { parse } from "@std/csv/parse"; +import emailAddresses from "email-addresses"; + +import { log, warn } from "./logger.ts"; +import { getConfigValue } from "./config.ts"; + +class MailParseError extends Error { + constructor( + message: string, + public readonly mail: Mail, + public readonly lineNumber?: number, + options?: ErrorOptions, + ) { + if (lineNumber != null) message += `(at line ${lineNumber})`; + super(message, options); + } +} + +interface ParsedMail { + sections: { + header: string; + body: string; + }; + /** + * The empty line between headers and body. + */ + sep: string; + eol: string; +} + +export class Mail { + date?: Date; + messageId?: string; + deliverMessage?: string; + + constructor(public raw: string) {} + + toUtf8Bytes(): Uint8Array { + const utf8Encoder = new TextEncoder(); + return utf8Encoder.encode(this.raw); + } + + toBase64(): string { + return encodeBase64(this.raw); + } + + simpleParse(): ParsedMail { + 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, + ); + } + + const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; + + if (eol !== sep) { + warn("Different EOLs (\\r\\n, \\n) found."); + } + + return { + sections: { + header: this.raw.slice(0, twoEolMatch.index!), + body: this.raw.slice(twoEolMatch.index! + eol.length + sep.length), + }, + sep, + eol, + }; + } + + simpleParseHeaders(): [key: string, value: string][] { + const { sections } = this.simpleParse(); + const headers: [string, string][] = []; + + let field: string | null = null; + let lineNumber = 1; + + const handleField = () => { + if (field == null) return; + const sepPos = field.indexOf(":"); + if (sepPos === -1) { + throw new MailParseError( + "No ':' in the header field.", + this, + lineNumber, + ); + } + headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]); + field = null; + }; + + for (const line of sections.header.trimEnd().split(/\r?\n|\r/)) { + if (line.match(/^\s/)) { + if (field == null) { + throw new MailParseError( + "Header field starts with a space.", + this, + lineNumber, + ); + } + field += line; + } else { + handleField(); + field = line; + } + lineNumber += 1; + } + + handleField(); + + return headers; + } + + simpleParseDate<T = undefined>( + invalidValue: T | undefined = undefined, + ): Date | T | undefined { + const headers = this.simpleParseHeaders(); + for (const [key, value] of headers) { + if (key.toLowerCase() === "date") { + const date = new Date(value); + if (isNaN(date.getTime())) { + warn(`Invalid date string (${value}) found in header.`); + return invalidValue; + } + return date; + } + } + 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(); + + this.raw = sections.header + eol + + headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep + + sections.body; + } +} + +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, + context: MailDeliverContext, + ): Promise<void>; + + async deliverRaw(rawMail: string): Promise<Mail> { + const mail = new Mail(rawMail); + await this.deliver(mail); + return mail; + } + + 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.callback(context); + } + + await this.doDeliver(context.mail, context); + + for (const hook of this.postHooks) { + await hook.callback(context); + } + + 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); + } + } + } +} |