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