diff options
Diffstat (limited to 'services/docker/mail-server/relay/mail.ts')
-rw-r--r-- | services/docker/mail-server/relay/mail.ts | 258 |
1 files changed, 126 insertions, 132 deletions
diff --git a/services/docker/mail-server/relay/mail.ts b/services/docker/mail-server/relay/mail.ts index 08335ed..9c12876 100644 --- a/services/docker/mail-server/relay/mail.ts +++ b/services/docker/mail-server/relay/mail.ts @@ -2,13 +2,13 @@ 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"; +import log from "./log.ts"; +import config from "./config.ts"; -class MailParseError extends Error { +class MailSimpleParseError extends Error { constructor( message: string, - public readonly mail: Mail, + public readonly text: string, public readonly lineNumber?: number, options?: ErrorOptions, ) { @@ -17,62 +17,69 @@ class MailParseError extends Error { } } -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); +class MailSimpleParsedHeaders extends Array<[key: string, value: string]> { + date(invalidToUndefined: boolean = true): Date | undefined { + for (const [key, value] of this) { + if (key.toLowerCase() === "date") { + const date = new Date(value); + if (invalidToUndefined && isNaN(date.getTime())) { + log.warn(`Invalid date string (${value}) found in header.`); + return undefined; + } + return date; + } + } + return undefined; } - toBase64(): string { - return encodeBase64(this.raw); + recipients(options?: { domain?: string; headers?: string[] }): Set<string> { + const domain = options?.domain; + const headers = options?.headers ?? ["to", "cc", "bcc", "x-original-to"]; + const recipients = new Set<string>(); + for (const [key, value] of this) { + if (headers.includes(key.toLowerCase())) { + emailAddresses.parseAddressList(value)?.flatMap((a) => + a.type === "mailbox" ? a : a.addresses + )?.forEach(({ address }) => { + if (domain == null || address.endsWith(domain)) { + recipients.add(address); + } + }); + } + } + return recipients; } +} + +class MailSimpleParsedSections { + header: string; + body: string; + eol: string; + sep: string; - simpleParse(): ParsedMail { - const twoEolMatch = this.raw.match(/(\r?\n)(\r?\n)/); + constructor(raw: string) { + const twoEolMatch = raw.match(/(\r?\n)(\r?\n)/); if (twoEolMatch == null) { - throw new MailParseError( + throw new MailSimpleParseError( "No header/body section separator (2 successive EOLs) found.", - this, + raw, ); } const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; if (eol !== sep) { - warn("Different EOLs (\\r\\n, \\n) found."); + log.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, - }; + this.header = raw.slice(0, twoEolMatch.index!); + this.body = raw.slice(twoEolMatch.index! + eol.length + sep.length); + this.eol = eol; + this.sep = sep; } - simpleParseHeaders(): [key: string, value: string][] { - const { sections } = this.simpleParse(); - const headers: [string, string][] = []; + headers(): MailSimpleParsedHeaders { + const headers = new MailSimpleParsedHeaders(); let field: string | null = null; let lineNumber = 1; @@ -81,9 +88,9 @@ export class Mail { if (field == null) return; const sepPos = field.indexOf(":"); if (sepPos === -1) { - throw new MailParseError( + throw new MailSimpleParseError( "No ':' in the header field.", - this, + this.header, lineNumber, ); } @@ -91,12 +98,12 @@ export class Mail { field = null; }; - for (const line of sections.header.trimEnd().split(/\r?\n|\r/)) { + for (const line of this.header.trimEnd().split(/\r?\n|\r/)) { if (line.match(/^\s/)) { if (field == null) { - throw new MailParseError( + throw new MailSimpleParseError( "Header field starts with a space.", - this, + this.header, lineNumber, ); } @@ -112,92 +119,69 @@ export class Mail { 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; +export class Mail { + constructor(public raw: string) {} + + toUtf8Bytes(): Uint8Array { + const utf8Encoder = new TextEncoder(); + return utf8Encoder.encode(this.raw); } - 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; + toBase64(): string { + return encodeBase64(this.raw); + } + + startSimpleParse() { + return { sections: () => new MailSimpleParsedSections(this.raw) }; } // TODO: Add folding. appendHeaders(headers: [key: string, value: string][]) { - const { sections, sep, eol } = this.simpleParse(); + const { header, body, sep, eol } = this.startSimpleParse().sections(); - this.raw = sections.header + eol + + this.raw = header + eol + headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep + - sections.body; + body; } } export type MailDeliverResultKind = "done" | "fail" | "retry"; -export interface MailDeliverReceiptResult { +export interface MailDeliverRecipientResult { kind: MailDeliverResultKind; message: string; cause?: unknown; } export class MailDeliverResult { - readonly receipts: Map<string, MailDeliverReceiptResult> = new Map(); + message: string = ""; + recipients: Map<string, MailDeliverRecipientResult> = new Map(); - add( - receipt: string, - kind: MailDeliverResultKind, - message: string, - cause?: unknown, - ) { - this.receipts.set(receipt, { kind, message, cause }); - } + constructor(public mail: Mail) {} - set(receipt: string, result: MailDeliverReceiptResult) { - this.receipts.set(receipt, result); + hasError(): boolean { + return this.recipients.values().some((r) => r.kind !== "done"); } [Symbol.for("Deno.customInspect")]() { return [ - ...this.receipts.entries().map(([receipt, result]) => - `${receipt}[${result.kind}]: ${result.message}` + `message: ${this.message}`, + ...this.recipients.entries().map(([recipient, result]) => + `${recipient} [${result.kind}]: ${result.message}` ), ].join("\n"); } } export class MailDeliverContext { - readonly receipts: Set<string> = new Set(); - readonly result: MailDeliverResult = new MailDeliverResult(); + readonly recipients: Set<string> = new Set(); + readonly result; - constructor(public mail: Mail) {} + constructor(public mail: Mail) { + this.result = new MailDeliverResult(this.mail); + } } export interface MailDeliverHook { @@ -214,16 +198,17 @@ export abstract class MailDeliverer { context: MailDeliverContext, ): Promise<void>; - async deliverRaw(rawMail: string): Promise<Mail> { - const mail = new Mail(rawMail); - await this.deliver(mail); - return mail; + async deliverRaw(rawMail: string) { + return await this.deliver({ mail: new Mail(rawMail) }); } - async deliver(mail: Mail): Promise<MailDeliverResult> { - log(`Begin to deliver mail via ${this.name}...`); + async deliver( + options: { mail: Mail; recipients?: string[] }, + ): Promise<MailDeliverResult> { + log.info(`Begin to deliver mail via ${this.name}...`); - const context = new MailDeliverContext(mail); + const context = new MailDeliverContext(options.mail); + options.recipients?.forEach((r) => context.recipients.add(r)); for (const hook of this.preHooks) { await hook.callback(context); @@ -235,9 +220,10 @@ export abstract class MailDeliverer { await hook.callback(context); } - log("Deliver result:", context.result); + log.info("Deliver result:"); + log.info(context.result); - if (context.result.receipts.values().some((r) => r.kind !== "done")) { + if (context.result.hasError()) { throw new Error("Mail failed to deliver."); } @@ -245,36 +231,42 @@ export abstract class MailDeliverer { } } -export class ReceiptsFromHeadersHook implements MailDeliverHook { +export class RecipientFromHeadersHook implements MailDeliverHook { callback(context: MailDeliverContext) { - if (context.receipts.size !== 0) { - warn("Receipts are already filled. Won't set them with ones in headers."); + if (context.recipients.size !== 0) { + log.warn( + "Recipients are already filled. Won't set them with ones in headers.", + ); } else { - context.mail.simpleParseReceipts({ - domain: getConfigValue("mailDomain"), - }).forEach((r) => context.receipts.add(r)); + context.mail.startSimpleParse().sections().headers().recipients({ + domain: config.get("mailDomain"), + }).forEach((r) => context.recipients.add(r)); - if (context.receipts.size === 0) { - warn("No receipts found from mail headers."); - } + log.info( + "Recipients found from mail headers: ", + [...context.recipients].join(" "), + ); } return Promise.resolve(); } } -export class FallbackReceiptsHook implements MailDeliverHook { +export class FallbackRecipientHook 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)); + if (context.recipients.size === 0) { + log.info( + "No recipients, fill with fallback: ", + [...this.fallback].join(" "), + ); + this.fallback.forEach((a) => context.recipients.add(a)); } return Promise.resolve(); } } -export class AliasFileMailHook implements MailDeliverHook { +export class AliasRecipientMailHook implements MailDeliverHook { #aliasFile; constructor(aliasFile: string) { @@ -284,7 +276,7 @@ export class AliasFileMailHook implements MailDeliverHook { async #parseAliasFile(): Promise<Map<string, string>> { const result = new Map(); if ((await Deno.stat(this.#aliasFile)).isFile) { - log(`Found receipts alias file: ${this.#aliasFile}.`); + log.info(`Found recipients alias file: ${this.#aliasFile}.`); const text = await Deno.readTextFile(this.#aliasFile); const csv = parse(text); for (const [real, ...aliases] of csv) { @@ -296,12 +288,14 @@ export class AliasFileMailHook implements MailDeliverHook { 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); + for (const recipient of [...context.recipients]) { + const realRecipients = aliases.get(recipient); + if (realRecipients != null) { + log.info( + `Recipient alias resolved: ${recipient} => ${realRecipients}.`, + ); + context.recipients.delete(recipient); + context.recipients.add(realRecipients); } } } |