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