diff options
Diffstat (limited to 'services/docker/mail-server/aws-sendmail/mail.ts')
-rw-r--r-- | services/docker/mail-server/aws-sendmail/mail.ts | 244 |
1 files changed, 244 insertions, 0 deletions
diff --git a/services/docker/mail-server/aws-sendmail/mail.ts b/services/docker/mail-server/aws-sendmail/mail.ts new file mode 100644 index 0000000..b974e49 --- /dev/null +++ b/services/docker/mail-server/aws-sendmail/mail.ts @@ -0,0 +1,244 @@ +import { encodeBase64 } from "@std/encoding/base64"; +import { getLogger } from "./logger.ts"; +import { generateTimeStringForFileName } from "./util.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; + +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); + } +} + +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; + body: string; + }; + /** + * The empty line between headers and body. + */ + sep: string; + eol: string; +} + +export class Mail { + messageId: string | null = null; + awsMessageId: string | null = null; + deliverState: MailDeliverState = { kind: "not-sent" }; + + 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 to ${deliverer.destination}: ${reason}`, + this, + { cause }, + ); + this.deliverState = { "kind": "error", deliverer, error }; + throw error; + } + + 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|\n|\r)(\r\n|\n|\r)/); + // "\r\n" is a false positive. + if (twoEolMatch == null || twoEolMatch[0] === "\r\n") { + throw new MailParseError( + "No header/body section separator (2 successive EOLs) found.", + this, + ); + } + + const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; + + if (eol !== sep) { + getLogger().warn( + `Different EOLs (${eolNames.get(eol)} and ${eolNames.get(sep)}) found.`, + ); + } + + return { + sections: { + header: this.raw.slice(0, twoEolMatch.index! + eol.length), + 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; + } + + /** + * Find the Date header and parse it to date. + * @param invalidToNull Whether to convert invalid date to null. Default is false. + * @returns Parsed date (may be an invalid date if `invalidToNull` us false). + * `null` if the date header is missing or parsing fails and `invalidToNull` + * is true. + */ + simpleGetDate<T = null>(invalidValue: T | null = null): Date | T | null { + const headers = this.simpleParseHeaders(); + for (const [key, value] of headers) { + if (key.toLowerCase() === "date") { + const date = new Date(value); + if (isNaN(date.getTime())) { + getLogger().warn(`Invalid date string (${value}) found in header.`); + return invalidValue; + } + return date; + } + } + return null; + } + + simpleGetDateString<T>(fallback: T): string | T { + const date = this.simpleGetDate(); + if (date == null) return fallback; + return generateTimeStringForFileName(date, true); + } + + // TODO: Add folding. + appendHeaders(headers: [key: string, value: string][]) { + const { sections, sep, eol } = this.simpleParse(); + + this.raw = sections.header + + headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep + + sections.body; + } +} + +type MailDeliverHook = (mail: Mail) => Promise<void>; + +export abstract class MailDeliverer { + preHooks: MailDeliverHook[] = []; + postHooks: MailDeliverHook[] = []; + + constructor(public readonly destination: string) {} + + protected doPrepare(_mail: Mail): Promise<void> { + return Promise.resolve(); + } + protected abstract doDeliver(mail: Mail): Promise<void>; + protected doFinalize(_mail: Mail): Promise<void> { + return Promise.resolve(); + } + + async deliverRaw(raw: string): Promise<void> { + const mail = new Mail(raw); + await this.deliver(mail); + } + + async deliver(mail: Mail): Promise<void> { + this.doPrepare(mail); + + for (const hook of this.preHooks) { + await hook(mail); + } + + await this.doDeliver(mail); + + if (mail.deliverState.kind === "not-sent") { + mail.setDelivered(this); + } + + for (const hook of this.postHooks) { + await hook(mail); + } + + await this.doFinalize(mail); + } +} |