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.ts258
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);
}
}
}