aboutsummaryrefslogtreecommitdiff
path: root/deno/mail-relay/mail.ts
diff options
context:
space:
mode:
Diffstat (limited to 'deno/mail-relay/mail.ts')
-rw-r--r--deno/mail-relay/mail.ts284
1 files changed, 92 insertions, 192 deletions
diff --git a/deno/mail-relay/mail.ts b/deno/mail-relay/mail.ts
index f1fc892..94944b0 100644
--- a/deno/mail-relay/mail.ts
+++ b/deno/mail-relay/mail.ts
@@ -1,135 +1,28 @@
import { encodeBase64 } from "@std/encoding/base64";
import { parse } from "@std/csv/parse";
-import emailAddresses from "email-addresses";
+import { simpleParseMail } from "./mail-parsing.ts";
-class MailSimpleParseError extends Error {}
-
-class MailSimpleParsedHeaders {
- constructor(public fields: [key: string, value: string][]) {}
-
- getFirst(fieldKey: string): string | undefined {
- for (const [key, value] of this.fields) {
- if (key.toLowerCase() === fieldKey.toLowerCase()) return value;
- }
- return undefined;
- }
-
- messageId(): string | undefined {
- const messageIdField = this.getFirst("message-id");
- if (messageIdField == null) return undefined;
-
- const match = messageIdField.match(/\<(.*?)\>/);
- if (match != null) {
- return match[1];
- } else {
- console.warn("Invalid message-id header of mail: " + messageIdField);
- return undefined;
- }
- }
-
- date(invalidToUndefined: boolean = true): Date | undefined {
- const dateField = this.getFirst("date");
- if (dateField == null) return undefined;
-
- const date = new Date(dateField);
- if (invalidToUndefined && isNaN(date.getTime())) {
- console.warn(`Invalid date string (${dateField}) found in header.`);
- return undefined;
- }
- return date;
- }
-
- from(): string | undefined {
- const fromField = this.getFirst("from");
- if (fromField == null) return undefined;
+export class Mail {
+ #raw;
+ #parsed;
- const addr = emailAddresses.parseOneAddress(fromField);
- return addr?.type === "mailbox" ? addr.address : undefined;
+ constructor(raw: string) {
+ this.#raw = raw;
+ this.#parsed = simpleParseMail(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.fields) {
- 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;
+ get raw() {
+ return this.#raw;
}
-}
-
-class MailSimpleParsedSections {
- header: string;
- body: string;
- eol: string;
- sep: string;
- constructor(raw: string) {
- const twoEolMatch = raw.match(/(\r?\n)(\r?\n)/);
- if (twoEolMatch == null) {
- throw new MailSimpleParseError(
- "No header/body section separator (2 successive EOLs) found.",
- );
- }
-
- const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]];
-
- if (eol !== sep) {
- console.warn("Different EOLs (\\r\\n, \\n) found.");
- }
-
- this.header = raw.slice(0, twoEolMatch.index!);
- this.body = raw.slice(twoEolMatch.index! + eol.length + sep.length);
- this.eol = eol;
- this.sep = sep;
+ set raw(value) {
+ this.#raw = value;
+ this.#parsed = simpleParseMail(value);
}
- headers(): MailSimpleParsedHeaders {
- const headers = [] as [key: string, value: string][];
-
- let field: string | null = null;
- let lineNumber = 1;
-
- const handleField = () => {
- if (field == null) return;
- const sepPos = field.indexOf(":");
- if (sepPos === -1) {
- throw new MailSimpleParseError(`No ':' in the header line: ${field}`);
- }
- headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]);
- field = null;
- };
-
- for (const line of this.header.trimEnd().split(/\r?\n|\r/)) {
- if (line.match(/^\s/)) {
- if (field == null) {
- throw new MailSimpleParseError("Header section starts with a space.");
- }
- field += line;
- } else {
- handleField();
- field = line;
- }
- lineNumber += 1;
- }
-
- handleField();
-
- return new MailSimpleParsedHeaders(headers);
+ get parsed() {
+ return this.#parsed;
}
-}
-
-export class Mail {
- constructor(public raw: string) {}
toUtf8Bytes(): Uint8Array {
const utf8Encoder = new TextEncoder();
@@ -140,43 +33,39 @@ export class Mail {
return encodeBase64(this.raw);
}
- startSimpleParse() {
- return { sections: () => new MailSimpleParsedSections(this.raw) };
- }
-
simpleFindAllAddresses(): string[] {
const re = /,?\<?([a-z0-9_'+\-\.]+\@[a-z0-9_'+\-\.]+)\>?,?/gi;
return [...this.raw.matchAll(re)].map((m) => m[1]);
}
}
-export type MailDeliverResultKind = "done" | "fail";
-
export interface MailDeliverRecipientResult {
- kind: MailDeliverResultKind;
- message: string;
+ kind: "success" | "failure";
+ message?: string;
cause?: unknown;
}
export class MailDeliverResult {
- smtpMessage: string = "";
- recipients: Map<string, MailDeliverRecipientResult> = new Map();
-
+ message?: string;
+ smtpMessage?: string;
+ recipients = new Map<string, MailDeliverRecipientResult>();
constructor(public mail: Mail) {}
- hasError(): boolean {
- return (
- this.recipients.size === 0 ||
- this.recipients.values().some((r) => r.kind !== "done")
- );
+ get hasFailure() {
+ return this.recipients.values().some((v) => v.kind !== "success");
}
- [Symbol.for("Deno.customInspect")]() {
- return [
- ...this.recipients.entries().map(([recipient, result]) =>
- `${recipient} [${result.kind}]: ${result.message}`
- ),
- ].join("\n");
+ generateLogMessage(prefix: string) {
+ const lines = [];
+ if (this.message != null) lines.push(`${prefix} message: ${this.message}`);
+ if (this.smtpMessage != null) {
+ lines.push(`${prefix} smtpMessage: ${this.smtpMessage}`);
+ }
+ for (const [name, result] of this.recipients.entries()) {
+ const { kind, message, cause } = result;
+ lines.push(`${prefix} (${name}): ${kind} ${message} ${cause}`);
+ }
+ return lines.join("\n");
}
}
@@ -184,7 +73,7 @@ export class MailDeliverContext {
readonly recipients: Set<string> = new Set();
readonly result;
- constructor(public mail: Mail) {
+ constructor(public logTag: string, public mail: Mail) {
this.result = new MailDeliverResult(this.mail);
}
}
@@ -194,10 +83,15 @@ export interface MailDeliverHook {
}
export abstract class MailDeliverer {
- abstract readonly name: string;
+ #counter = 1;
+ #last?: Promise<void>;
+
+ abstract name: string;
preHooks: MailDeliverHook[] = [];
postHooks: MailDeliverHook[] = [];
+ constructor(public sync: boolean) {}
+
protected abstract doDeliver(
mail: Mail,
context: MailDeliverContext,
@@ -207,15 +101,7 @@ export abstract class MailDeliverer {
return await this.deliver({ mail: new Mail(rawMail) });
}
- async deliver(options: {
- mail: Mail;
- recipients?: string[];
- }): Promise<MailDeliverResult> {
- console.info(`Begin to deliver mail via ${this.name}...`);
-
- const context = new MailDeliverContext(options.mail);
- options.recipients?.forEach((r) => context.recipients.add(r));
-
+ async #deliverCore(context: MailDeliverContext) {
for (const hook of this.preHooks) {
await hook.callback(context);
}
@@ -225,35 +111,46 @@ export abstract class MailDeliverer {
for (const hook of this.postHooks) {
await hook.callback(context);
}
-
- console.info("Deliver result:");
- console.info(context.result);
-
- if (context.result.hasError()) {
- throw new Error("Mail failed to deliver.");
- }
-
- return context.result;
}
-}
-
-export abstract class SyncMailDeliverer extends MailDeliverer {
- #last: Promise<void> = Promise.resolve();
- override async deliver(options: {
+ async deliver(options: {
mail: Mail;
recipients?: string[];
+ logTag?: string;
}): Promise<MailDeliverResult> {
- console.info(
- "The mail deliverer is sync. Wait for last delivering done...",
- );
- await this.#last;
- const result = super.deliver(options);
- this.#last = result.then(
- () => {},
- () => {},
+ const logTag = options.logTag ?? `[${this.name} ${this.#counter}]`;
+ this.#counter++;
+
+ if (this.#last != null) {
+ console.info(logTag, "Wait for last delivering done...");
+ await this.#last;
+ }
+
+ const context = new MailDeliverContext(
+ logTag,
+ options.mail,
);
- return result;
+ options.recipients?.forEach((r) => context.recipients.add(r));
+
+ console.info(context.logTag, "Begin to deliver mail...");
+
+ const deliverPromise = this.#deliverCore(context);
+
+ if (this.sync) {
+ this.#last = deliverPromise.then(() => {}, () => {});
+ }
+
+ await deliverPromise;
+ this.#last = undefined;
+
+ console.info(context.logTag, "Deliver result:");
+ console.info(context.result.generateLogMessage(context.logTag));
+
+ if (context.result.hasFailure) {
+ throw new Error("Failed to deliver to some recipients.");
+ }
+
+ return context.result;
}
}
@@ -263,21 +160,18 @@ export class RecipientFromHeadersHook implements MailDeliverHook {
callback(context: MailDeliverContext) {
if (context.recipients.size !== 0) {
console.warn(
- "Recipients are already filled. Won't set them with ones in headers.",
+ context.logTag,
+ "Recipients are already filled, skip inferring from headers.",
);
} else {
- context.mail
- .startSimpleParse()
- .sections()
- .headers()
- .recipients({
- domain: this.mailDomain,
- })
- .forEach((r) => context.recipients.add(r));
+ [...context.mail.parsed.recipients].filter((r) =>
+ r.endsWith("@" + this.mailDomain)
+ ).forEach((r) => context.recipients.add(r));
console.info(
- "Recipients found from mail headers: " +
- [...context.recipients].join(", "),
+ context.logTag,
+ "Use recipients inferred from mail headers:",
+ [...context.recipients].join(", "),
);
}
return Promise.resolve();
@@ -290,7 +184,8 @@ export class FallbackRecipientHook implements MailDeliverHook {
callback(context: MailDeliverContext) {
if (context.recipients.size === 0) {
console.info(
- "No recipients, fill with fallback: " + [...this.fallback].join(", "),
+ context.logTag,
+ "Use fallback recipients:" + [...this.fallback].join(", "),
);
this.fallback.forEach((a) => context.recipients.add(a));
}
@@ -305,25 +200,30 @@ export class AliasRecipientMailHook implements MailDeliverHook {
this.#aliasFile = aliasFile;
}
- async #parseAliasFile(): Promise<Map<string, string>> {
+ async #parseAliasFile(logTag: string): Promise<Map<string, string>> {
const result = new Map();
if ((await Deno.stat(this.#aliasFile)).isFile) {
- console.info(`Found recipients 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));
}
+ } else {
+ console.warn(
+ logTag,
+ `Recipient alias file ${this.#aliasFile} is not found.`,
+ );
}
return result;
}
async callback(context: MailDeliverContext) {
- const aliases = await this.#parseAliasFile();
+ const aliases = await this.#parseAliasFile(context.logTag);
for (const recipient of [...context.recipients]) {
const realRecipients = aliases.get(recipient);
if (realRecipients != null) {
console.info(
+ context.logTag,
`Recipient alias resolved: ${recipient} => ${realRecipients}.`,
);
context.recipients.delete(recipient);