aboutsummaryrefslogtreecommitdiff
path: root/deno
diff options
context:
space:
mode:
Diffstat (limited to 'deno')
-rw-r--r--deno/base/deno.json1
-rw-r--r--deno/base/log.ts60
-rw-r--r--deno/mail-relay/app.ts7
-rw-r--r--deno/mail-relay/aws/app.ts29
-rw-r--r--deno/mail-relay/aws/deliver.ts10
-rw-r--r--deno/mail-relay/aws/fetch.ts8
-rw-r--r--deno/mail-relay/aws/mail.ts31
-rw-r--r--deno/mail-relay/dovecot.ts228
-rw-r--r--deno/mail-relay/dumb-smtp-server.ts18
-rw-r--r--deno/mail-relay/mail-parsing.ts196
-rw-r--r--deno/mail-relay/mail.test.ts23
-rw-r--r--deno/mail-relay/mail.ts198
12 files changed, 444 insertions, 365 deletions
diff --git a/deno/base/deno.json b/deno/base/deno.json
index dabc02a..52baaa5 100644
--- a/deno/base/deno.json
+++ b/deno/base/deno.json
@@ -5,6 +5,5 @@
".": "./lib.ts",
"./config": "./config.ts",
"./cron": "./cron.ts",
- "./log": "./log.ts"
}
}
diff --git a/deno/base/log.ts b/deno/base/log.ts
deleted file mode 100644
index 940f569..0000000
--- a/deno/base/log.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { join } from "@std/path";
-
-import { toFileNameString } from "./lib.ts";
-
-export interface ExternalLogStream extends Disposable {
- stream: WritableStream;
-}
-
-export class LogFileProvider {
- #directory: string;
-
- constructor(directory: string) {
- this.#directory = directory;
- Deno.mkdirSync(directory, { recursive: true });
- }
-
- async createExternalLogStream(
- name: string,
- options?: {
- noTime?: boolean;
- },
- ): Promise<ExternalLogStream> {
- if (name.includes("/")) {
- throw new Error(`External log stream's name (${name}) contains '/'.`);
- }
-
- const logPath = join(
- this.#directory,
- options?.noTime === true
- ? name
- : `${name}-${toFileNameString(new Date())}`,
- );
-
- const file = await Deno.open(logPath, {
- read: false,
- write: true,
- append: true,
- create: true,
- });
- return {
- stream: file.writable,
- [Symbol.dispose]: file[Symbol.dispose].bind(file),
- };
- }
-
- async createExternalLogStreamsForProgram(
- program: string,
- ): Promise<{ stdout: WritableStream; stderr: WritableStream } & Disposable> {
- const stdout = await this.createExternalLogStream(`${program}-stdout`);
- const stderr = await this.createExternalLogStream(`${program}-stderr`);
- return {
- stdout: stdout.stream,
- stderr: stderr.stream,
- [Symbol.dispose]: () => {
- stdout[Symbol.dispose]();
- stderr[Symbol.dispose]();
- },
- };
- }
-}
diff --git a/deno/mail-relay/app.ts b/deno/mail-relay/app.ts
index eeffc12..bb35378 100644
--- a/deno/mail-relay/app.ts
+++ b/deno/mail-relay/app.ts
@@ -1,8 +1,6 @@
import { Hono } from "hono";
import { logger as honoLogger } from "hono/logger";
-import { LogFileProvider } from "@crupest/base/log";
-
import {
AliasRecipientMailHook,
FallbackRecipientHook,
@@ -13,20 +11,21 @@ import { DovecotMailDeliverer } from "./dovecot.ts";
import { DumbSmtpServer } from "./dumb-smtp-server.ts";
export function createInbound(
- logFileProvider: LogFileProvider,
{
fallback,
mailDomain,
aliasFile,
ldaPath,
+ doveadmPath,
}: {
fallback: string[];
mailDomain: string;
aliasFile: string;
ldaPath: string;
+ doveadmPath: string;
},
) {
- const deliverer = new DovecotMailDeliverer(logFileProvider, ldaPath);
+ const deliverer = new DovecotMailDeliverer(ldaPath, doveadmPath);
deliverer.preHooks.push(
new RecipientFromHeadersHook(mailDomain),
new FallbackRecipientHook(new Set(fallback)),
diff --git a/deno/mail-relay/aws/app.ts b/deno/mail-relay/aws/app.ts
index cb275ae..a8a9895 100644
--- a/deno/mail-relay/aws/app.ts
+++ b/deno/mail-relay/aws/app.ts
@@ -6,7 +6,6 @@ import { FetchHttpHandler } from "@smithy/fetch-http-handler";
// @ts-types="npm:@types/yargs"
import yargs from "yargs";
-import { LogFileProvider } from "@crupest/base/log";
import { ConfigDefinition, ConfigProvider } from "@crupest/base/config";
import { CronTask } from "@crupest/base/cron";
@@ -19,6 +18,7 @@ import {
import { AwsMailDeliverer } from "./deliver.ts";
import { AwsMailFetcher, AwsS3MailConsumer } from "./fetch.ts";
import { createHono, createInbound, createSmtp, sendMail } from "../app.ts";
+import { DovecotMailDeliverer } from "../dovecot.ts";
const PREFIX = "crupest-mail-server";
const CONFIG_DEFINITIONS = {
@@ -47,6 +47,10 @@ const CONFIG_DEFINITIONS = {
description: "full path of lda executable",
default: "/dovecot/libexec/dovecot/dovecot-lda",
},
+ doveadmPath: {
+ description: "full path of doveadm executable",
+ default: "/dovecot/bin/doveadm",
+ },
inboundFallback: {
description: "comma separated addresses used as fallback recipients",
default: "",
@@ -96,14 +100,18 @@ function createAwsOptions({
function createOutbound(
awsOptions: ReturnType<typeof createAwsOptions>,
db: DbService,
+ local?: DovecotMailDeliverer,
) {
const deliverer = new AwsMailDeliverer(awsOptions);
deliverer.preHooks.push(
new AwsMailMessageIdRewriteHook(db.messageIdToAws.bind(db)),
);
deliverer.postHooks.push(
- new AwsMailMessageIdSaveHook((original, aws) =>
- db.addMessageIdMap({ message_id: original, aws_message_id: aws }).then()
+ new AwsMailMessageIdSaveHook(
+ async (original, aws, context) => {
+ await db.addMessageIdMap({ message_id: original, aws_message_id: aws });
+ void local?.saveNewSent(context.mail, original);
+ },
),
);
return deliverer;
@@ -155,10 +163,7 @@ function createCron(fetcher: AwsMailFetcher, consumer: AwsS3MailConsumer) {
function createBaseServices() {
const config = new ConfigProvider(PREFIX, CONFIG_DEFINITIONS);
Deno.mkdirSync(config.get("dataPath"), { recursive: true });
- const logFileProvider = new LogFileProvider(
- join(config.get("dataPath"), "log"),
- );
- return { config, logFileProvider };
+ return { config };
}
function createAwsFetchOnlyServices() {
@@ -177,11 +182,12 @@ function createAwsFetchOnlyServices() {
function createAwsRecycleOnlyServices() {
const services = createAwsFetchOnlyServices();
- const { config, logFileProvider } = services;
+ const { config } = services;
- const inbound = createInbound(logFileProvider, {
+ const inbound = createInbound({
fallback: config.getList("inboundFallback"),
ldaPath: config.get("ldaPath"),
+ doveadmPath: config.get("doveadmPath"),
aliasFile: join(config.get("dataPath"), "aliases.csv"),
mailDomain: config.get("mailDomain"),
});
@@ -190,12 +196,13 @@ function createAwsRecycleOnlyServices() {
return { ...services, inbound, recycler };
}
+
function createAwsServices() {
const services = createAwsRecycleOnlyServices();
- const { config, awsOptions } = services;
+ const { config, awsOptions, inbound } = services;
const dbService = new DbService(join(config.get("dataPath"), "db.sqlite"));
- const outbound = createOutbound(awsOptions, dbService);
+ const outbound = createOutbound(awsOptions, dbService, inbound);
return { ...services, dbService, outbound };
}
diff --git a/deno/mail-relay/aws/deliver.ts b/deno/mail-relay/aws/deliver.ts
index 4dd4b3a..52438cb 100644
--- a/deno/mail-relay/aws/deliver.ts
+++ b/deno/mail-relay/aws/deliver.ts
@@ -44,15 +44,15 @@ export class AwsMailDeliverer extends SyncMailDeliverer {
`${res.MessageId}@${this.#aws.region}.amazonses.com`;
}
+ context.result.smtpMessage = `AWS Message ID: ${context.result.awsMessageId}`;
context.result.recipients.set("*", {
- kind: "done",
- message:
- `Successfully called aws send-email, message id ${context.result.awsMessageId}.`,
+ kind: "success",
+ message: `Succeeded to call aws send-email api.`,
});
} catch (cause) {
context.result.recipients.set("*", {
- kind: "fail",
- message: "An error was thrown when calling aws send-email." + cause,
+ kind: "failure",
+ message: "A JS error was thrown when calling aws send-email." + cause,
cause,
});
}
diff --git a/deno/mail-relay/aws/fetch.ts b/deno/mail-relay/aws/fetch.ts
index 9278e63..b9954c8 100644
--- a/deno/mail-relay/aws/fetch.ts
+++ b/deno/mail-relay/aws/fetch.ts
@@ -50,8 +50,6 @@ export class AwsMailFetcher {
}
async listLiveMails(): Promise<string[]> {
- console.info("Begin to retrieve live mails.");
-
const listCommand = new ListObjectsV2Command({
Bucket: this.#bucket,
Prefix: this.#livePrefix,
@@ -99,11 +97,7 @@ export class AwsMailFetcher {
await consumer(rawMail, s3Key);
console.info(`Done consuming s3 mail ${s3Key}.`);
- const date = new Mail(rawMail)
- .startSimpleParse()
- .sections()
- .headers()
- .date();
+ const { date } = new Mail(rawMail).parsed;
const dateString = date != null
? toFileNameString(date, true)
: "invalid-date";
diff --git a/deno/mail-relay/aws/mail.ts b/deno/mail-relay/aws/mail.ts
index cc05d23..0f86705 100644
--- a/deno/mail-relay/aws/mail.ts
+++ b/deno/mail-relay/aws/mail.ts
@@ -8,42 +8,43 @@ export class AwsMailMessageIdRewriteHook implements MailDeliverHook {
}
async callback(context: MailDeliverContext): Promise<void> {
- console.info("Rewrite message ids...");
const addresses = context.mail.simpleFindAllAddresses();
- console.info(`Addresses found in mail: ${addresses.join(", ")}.`);
for (const address of addresses) {
const awsMessageId = await this.#lookup(address);
if (awsMessageId != null && awsMessageId.length !== 0) {
- console.info(`Rewrite ${address} to ${awsMessageId}.`);
+ console.info(`Rewrite address-line string in mail: ${address} => ${awsMessageId}.`);
context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId);
}
}
- console.info("Done rewrite message ids.");
}
}
export class AwsMailMessageIdSaveHook implements MailDeliverHook {
readonly #record;
- constructor(record: (original: string, aws: string) => Promise<void>) {
+ constructor(
+ record: (
+ original: string,
+ aws: string,
+ context: MailDeliverContext,
+ ) => Promise<void>,
+ ) {
this.#record = record;
}
async callback(context: MailDeliverContext): Promise<void> {
- console.info("Save aws message ids...");
- const messageId = context.mail
- .startSimpleParse()
- .sections()
- .headers()
- .messageId();
+ const messageId = context.mail.parsed.messageId;
if (messageId == null) {
- console.info("Original mail does not have message id. Skip saving.");
+ console.warn("Original mail does not have message id, skip saving message id map.");
return;
}
if (context.result.awsMessageId != null) {
- console.info(`Saving ${messageId} => ${context.result.awsMessageId}.`);
- await this.#record(messageId, context.result.awsMessageId);
+ console.info(`Save message id map: ${messageId} => ${context.result.awsMessageId}.`);
+ context.mail.raw = context.mail.raw.replaceAll(
+ messageId,
+ context.result.awsMessageId,
+ );
+ await this.#record(messageId, context.result.awsMessageId, context);
}
- console.info("Done save message ids.");
}
}
diff --git a/deno/mail-relay/dovecot.ts b/deno/mail-relay/dovecot.ts
index bace225..8e6e754 100644
--- a/deno/mail-relay/dovecot.ts
+++ b/deno/mail-relay/dovecot.ts
@@ -1,99 +1,189 @@
-import { basename } from "@std/path";
+import { Mail, MailDeliverContext, MailDeliverer } from "./mail.ts";
+
+// https://doc.dovecot.org/main/core/man/dovecot-lda.1.html
+const ldaExitCodeMessageMap = new Map<number, string>();
+ldaExitCodeMessageMap.set(67, "recipient user not known");
+ldaExitCodeMessageMap.set(75, "temporary error");
+
+type CommandResult = {
+ kind: "exit-success" | "exit-failure";
+ status: Deno.CommandStatus;
+ logMessage: string;
+} | { kind: "throw"; cause: unknown; logMessage: string };
+
+async function runCommand(
+ bin: string,
+ options: {
+ args: string[];
+ stdin?: Uint8Array;
+ suppressResultLog?: boolean;
+ errorCodeMessageMap?: Map<number, string>;
+ },
+): Promise<CommandResult> {
+ const { args, stdin, suppressResultLog, errorCodeMessageMap } = options;
+
+ console.info(`Run external command ${bin} ${args.join(" ")}`);
+
+ try {
+ // Create and spawn process.
+ const command = new Deno.Command(bin, {
+ args,
+ stdin: stdin == null ? "null" : "piped",
+ });
+ const process = command.spawn();
+
+ // Write stdin if any.
+ if (stdin != null) {
+ const writer = process.stdin.getWriter();
+ await writer.write(stdin);
+ writer.close();
+ }
-import { LogFileProvider } from "@crupest/base/log";
+ // Wait for process to exit.
+ const status = await process.status;
-import { Mail, MailDeliverContext, MailDeliverer } from "./mail.ts";
+ // Build log message string.
+ let message = `External command exited with code ${status.code}`;
+ if (status.signal != null) message += ` (signal: ${status.signal})`;
+ if (errorCodeMessageMap != null && errorCodeMessageMap.has(status.code)) {
+ message += `, ${errorCodeMessageMap.get(status.code)}`;
+ }
+ message += ".";
+ suppressResultLog || console.log(message);
+
+ // Return result.
+ return {
+ kind: status.success ? "exit-success" : "exit-failure",
+ status,
+ logMessage: message,
+ };
+ } catch (cause) {
+ const message = `A JS error was thrown when invoking external command:`;
+ suppressResultLog || console.log(message, cause);
+ return { kind: "throw", cause, logMessage: message + " " + cause };
+ }
+}
export class DovecotMailDeliverer extends MailDeliverer {
readonly name = "dovecot";
- readonly #logFileProvider;
readonly #ldaPath;
+ readonly #doveadmPath;
- constructor(logFileProvider: LogFileProvider, ldaPath: string) {
+ constructor(
+ ldaPath: string,
+ doveadmPath: string,
+ ) {
super();
- this.#logFileProvider = logFileProvider;
this.#ldaPath = ldaPath;
+ this.#doveadmPath = doveadmPath;
}
protected override async doDeliver(
mail: Mail,
context: MailDeliverContext,
): Promise<void> {
- const ldaPath = this.#ldaPath;
- const ldaBinName = basename(ldaPath);
- const utf8Stream = mail.toUtf8Bytes();
+ const utf8Bytes = mail.toUtf8Bytes();
const recipients = [...context.recipients];
if (recipients.length === 0) {
- context.result.message =
- "Failed to deliver to dovecot, no recipients are specified.";
- return;
+ throw new Error(
+ "Failed to deliver to dovecot, no recipients are specified.",
+ );
}
- console.info(`Deliver to dovecot users: ${recipients.join(", ")}.`);
-
for (const recipient of recipients) {
- try {
- const commandArgs = ["-d", recipient];
- console.info(`Run ${ldaBinName} ${commandArgs.join(" ")}...`);
-
- const ldaCommand = new Deno.Command(ldaPath, {
- args: commandArgs,
- stdin: "piped",
- stdout: "piped",
- stderr: "piped",
+ const result = await runCommand(
+ this.#ldaPath,
+ {
+ args: ["-d", recipient],
+ stdin: utf8Bytes,
+ suppressResultLog: true,
+ errorCodeMessageMap: ldaExitCodeMessageMap,
+ },
+ );
+
+ if (result.kind === "exit-success") {
+ context.result.recipients.set(recipient, {
+ kind: "success",
+ message: result.logMessage,
});
-
- const ldaProcess = ldaCommand.spawn();
- using logFiles = await this.#logFileProvider
- .createExternalLogStreamsForProgram(
- ldaBinName,
- );
- ldaProcess.stdout.pipeTo(logFiles.stdout);
- ldaProcess.stderr.pipeTo(logFiles.stderr);
-
- const stdinWriter = ldaProcess.stdin.getWriter();
- await stdinWriter.write(utf8Stream);
- await stdinWriter.close();
-
- const status = await ldaProcess.status;
-
- if (status.success) {
- context.result.recipients.set(recipient, {
- kind: "done",
- message: `${ldaBinName} exited with success.`,
- });
- } else {
- let message = `${ldaBinName} exited with error code ${status.code}`;
-
- if (status.signal != null) {
- message += ` (signal ${status.signal})`;
- }
-
- // https://doc.dovecot.org/main/core/man/dovecot-lda.1.html
- switch (status.code) {
- case 67:
- message += ", recipient user not known";
- break;
- case 75:
- message += ", temporary error";
- break;
- }
-
- message += ".";
-
- context.result.recipients.set(recipient, { kind: "fail", message });
- }
- } catch (cause) {
+ } else {
context.result.recipients.set(recipient, {
- kind: "fail",
- message: "An error is thrown when running lda: " + cause,
- cause,
+ kind: "failure",
+ message: result.logMessage,
});
}
}
+ }
+
+ #queryArgs(mailbox: string, messageId: string) {
+ return ["mailbox", mailbox, "header", "Message-ID", `<${messageId}>`];
+ }
+
+ async #deleteMail(
+ user: string,
+ mailbox: string,
+ messageId: string,
+ ): Promise<void> {
+ await runCommand(this.#doveadmPath, {
+ args: ["expunge", "-u", user, ...this.#queryArgs(mailbox, messageId)],
+ });
+ }
+
+ async #saveMail(user: string, mailbox: string, mail: Uint8Array) {
+ await runCommand(this.#doveadmPath, {
+ args: ["save", "-u", user, "-m", mailbox],
+ stdin: mail,
+ });
+ }
+
+ async #markAsRead(user: string, mailbox: string, messageId: string) {
+ await runCommand(this.#doveadmPath, {
+ args: [
+ "flags",
+ "add",
+ "-u",
+ user,
+ "\\Seen",
+ ...this.#queryArgs(mailbox, messageId),
+ ],
+ });
+ }
+
+ async saveNewSent(mail: Mail, messageIdToDelete: string) {
+ console.info("Save sent mails and delete ones with old message id.");
+
+ // Try to get from and recipients from headers.
+ const { messageId, from, recipients } = mail.parsed;
+
+ if (from == null) {
+ console.warn("Failed to get sender (from) in headers, skip saving.");
+ return;
+ }
+
+ if (recipients.has(from)) {
+ // So the mail should lie in the Inbox.
+ console.info(
+ "One recipient of the mail is the sender itself, skip saving.",
+ );
+ return;
+ }
+
+ await this.#saveMail(from, "Sent", mail.toUtf8Bytes());
+ if (messageId != null) {
+ await this.#markAsRead(from, "Sent", messageId);
+ } else {
+ console.warn(
+ "Message id of the mail is not found, skip marking as read.",
+ );
+ }
- console.info("Done handling all recipients.");
+ console.info("Schedule deletion of old mails at 15,30,60 seconds later.");
+ [15, 30, 60].forEach((seconds) =>
+ setTimeout(() => {
+ void this.#deleteMail(from, "Sent", messageIdToDelete);
+ }, 1000 * seconds)
+ );
}
}
diff --git a/deno/mail-relay/dumb-smtp-server.ts b/deno/mail-relay/dumb-smtp-server.ts
index ac7069c..818cc88 100644
--- a/deno/mail-relay/dumb-smtp-server.ts
+++ b/deno/mail-relay/dumb-smtp-server.ts
@@ -12,6 +12,7 @@ function createResponses(host: string, port: number | string) {
RCPT: "250 2.1.5 Recipient OK",
DATA: "354 Start mail input; end with <CRLF>.<CRLF>",
QUIT: `211 2.0.0 ${serverName} closing connection`,
+ ACTIVE_CLOSE: "421 4.7.0 Please open a new connection to send more emails",
INVALID: "500 5.5.1 Error: command not recognized",
} as const;
}
@@ -46,7 +47,7 @@ export class DumbSmtpServer {
const [decoder, encoder] = [new TextDecoder(), new TextEncoder()];
const decode = (data: Uint8Array) => decoder.decode(data);
const send = async (s: string) => {
- console.info(LOG_TAG, "Send line: " + s);
+ console.info(LOG_TAG, "Send line:" + s);
await writer.write(encoder.encode(s + CRLF));
};
@@ -69,7 +70,7 @@ export class DumbSmtpServer {
buffer = buffer.slice(eolPos + CRLF.length);
if (rawMail == null) {
- console.info(LOG_TAG, "Received line: " + line);
+ console.info(LOG_TAG, "Received line:" + line);
const upperLine = line.toUpperCase();
if (upperLine.startsWith("EHLO") || upperLine.startsWith("HELO")) {
await send(this.#responses["EHLO"]);
@@ -85,23 +86,22 @@ export class DumbSmtpServer {
await send(this.#responses["QUIT"]);
return;
} else {
- console.warn(LOG_TAG, "Unrecognized command from client: " + line);
+ console.warn(LOG_TAG, "Unrecognized command from client:" + line);
await send(this.#responses["INVALID"]);
return;
}
} else {
if (line === ".") {
try {
- console.info(LOG_TAG, "Mail data Received, begin to relay...");
- const { message } = await this.#deliverer.deliverRaw(rawMail);
- await send(`250 2.6.0 ${message}`);
+ console.info(LOG_TAG, "Mail data received, begin to relay...");
+ const { smtpMessage } = await this.#deliverer.deliverRaw(rawMail);
+ await send(`250 2.6.0 ${smtpMessage}`);
rawMail = null;
- console.info(LOG_TAG, "Relay succeeded.");
} catch (err) {
console.error(LOG_TAG, "Relay failed.", err);
await send("554 5.3.0 Error: check server log");
- return;
}
+ await send(this.#responses["ACTIVE_CLOSE"]);
} else {
const dataLine = line.startsWith("..") ? line.slice(1) : line;
rawMail += dataLine + CRLF;
@@ -123,7 +123,7 @@ export class DumbSmtpServer {
try {
await this.#handleConnection(conn);
} catch (cause) {
- console.error(LOG_TAG, "Tcp connection throws an error.", cause);
+ console.error(LOG_TAG, "Tcp connection throws an error:", cause);
}
}
}
diff --git a/deno/mail-relay/mail-parsing.ts b/deno/mail-relay/mail-parsing.ts
new file mode 100644
index 0000000..7e76257
--- /dev/null
+++ b/deno/mail-relay/mail-parsing.ts
@@ -0,0 +1,196 @@
+import emailAddresses from "email-addresses";
+
+class MailSimpleParseError extends Error {}
+
+function lazy<T>(calculator: () => T): () => T {
+ const tag = new Object();
+ let cache: typeof tag | T = tag;
+ return () => {
+ if (cache === tag) cache = calculator();
+ return cache as T;
+ };
+}
+
+class MailSimpleParsedHeaders {
+ #headerSection;
+
+ #headers = lazy(() => {
+ 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.#headerSection.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 headers;
+ });
+
+ #messageId = lazy(() => {
+ 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 = lazy(() => {
+ const dateField = this.#getFirst("date");
+ if (dateField == null) return undefined;
+
+ const date = new Date(dateField);
+ if (isNaN(date.getTime())) {
+ console.warn(`Invalid date string (${dateField}) found in header.`);
+ return undefined;
+ }
+ return date;
+ });
+
+ #from = lazy(() => {
+ const fromField = this.#getFirst("from");
+ if (fromField == null) return undefined;
+
+ const addr = emailAddresses.parseOneAddress(fromField);
+ return addr?.type === "mailbox" ? addr.address : undefined;
+ });
+
+ #recipients = lazy(() => {
+ const headers = ["to", "cc", "bcc", "x-original-to"];
+ const recipients = new Set<string>();
+ for (const [key, value] of this.#headers()) {
+ if (headers.includes(key.toLowerCase())) {
+ emailAddresses
+ .parseAddressList(value)
+ ?.flatMap((a) => (a.type === "mailbox" ? a : a.addresses))
+ ?.forEach(({ address }) => recipients.add(address));
+ }
+ }
+ return recipients;
+ });
+
+ constructor(headerSection: string) {
+ this.#headerSection = headerSection;
+ }
+
+ #getFirst(fieldKey: string): string | undefined {
+ for (const [key, value] of this.#headers()) {
+ if (key.toLowerCase() === fieldKey.toLowerCase()) return value;
+ }
+ return undefined;
+ }
+
+ get messageId() {
+ return this.#messageId();
+ }
+ get date() {
+ return this.#date();
+ }
+ get from() {
+ return this.#from();
+ }
+ get recipients() {
+ return this.#recipients();
+ }
+
+ toList(): [string, string][] {
+ return [...this.#headers()];
+ }
+}
+
+class MailSimpleParsed {
+ #raw;
+
+ #sections = lazy(() => {
+ const twoEolMatch = this.#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.");
+ }
+
+ return {
+ header: this.#raw.slice(0, twoEolMatch.index!),
+ body: this.#raw.slice(twoEolMatch.index! + eol.length + sep.length),
+ eol,
+ sep,
+ };
+ });
+
+ #headers = lazy(() => {
+ return new MailSimpleParsedHeaders(this.header);
+ });
+
+ constructor(raw: string) {
+ this.#raw = raw;
+ }
+
+ get header() {
+ return this.#sections().header;
+ }
+ get body() {
+ return this.#sections().body;
+ }
+ get sep() {
+ return this.#sections().sep;
+ }
+ get eol() {
+ return this.#sections().eol;
+ }
+
+ get headers() {
+ return this.#headers();
+ }
+
+ get date() {
+ return this.headers.date;
+ }
+
+ get messageId() {
+ return this.headers.messageId;
+ }
+
+ get from() {
+ return this.headers.from;
+ }
+
+ get recipients() {
+ return this.headers.recipients;
+ }
+}
+
+export function simpleParseMail(raw: string): MailSimpleParsed {
+ return new MailSimpleParsed(raw);
+}
diff --git a/deno/mail-relay/mail.test.ts b/deno/mail-relay/mail.test.ts
index cd0c38d..a864ea7 100644
--- a/deno/mail-relay/mail.test.ts
+++ b/deno/mail-relay/mail.test.ts
@@ -51,7 +51,7 @@ const mockToAddresses = [
describe("Mail", () => {
it("simple parse", () => {
- const parsed = new Mail(mockMailStr).startSimpleParse().sections();
+ const { parsed } = new Mail(mockMailStr);
expect(parsed.header).toEqual(mockHeaderStr);
expect(parsed.body).toEqual(mockBodyStr);
expect(parsed.sep).toBe("\n");
@@ -59,37 +59,26 @@ describe("Mail", () => {
});
it("simple parse crlf", () => {
- const parsed = new Mail(mockCrlfMailStr).startSimpleParse().sections();
+ const { parsed } = new Mail(mockCrlfMailStr);
expect(parsed.sep).toBe("\r\n");
expect(parsed.eol).toBe("\r\n");
});
it("simple parse date", () => {
expect(
- new Mail(mockMailStr).startSimpleParse().sections().headers().date(),
+ new Mail(mockMailStr).parsed.date,
).toEqual(new Date(mockDate));
});
it("simple parse headers", () => {
expect(
- new Mail(mockMailStr).startSimpleParse().sections().headers().fields,
+ new Mail(mockMailStr).parsed.headers.toList(),
).toEqual(mockHeaders.map((h) => [h[0], " " + h[1].replaceAll("\n", "")]));
});
it("parse recipients", () => {
const mail = new Mail(mockMailStr);
- expect([
- ...mail.startSimpleParse().sections().headers().recipients(),
- ]).toEqual([...mockToAddresses, mockCcAddress]);
- expect([
- ...mail.startSimpleParse().sections().headers().recipients({
- domain: "example.com",
- }),
- ]).toEqual(
- [...mockToAddresses, mockCcAddress].filter((a) =>
- a.endsWith("example.com")
- ),
- );
+ expect([...mail.parsed.recipients]).toEqual([...mockToAddresses, mockCcAddress]);
});
it("find all addresses", () => {
@@ -113,7 +102,7 @@ describe("MailDeliverer", () => {
class MockMailDeliverer extends MailDeliverer {
name = "mock";
override doDeliver = fn((_: Mail, ctx: MailDeliverContext) => {
- ctx.result.recipients.set("*", { kind: "done", message: "success" });
+ ctx.result.recipients.set("*", { kind: "success", message: "success message" });
return Promise.resolve();
}) as MailDeliverer["doDeliver"];
}
diff --git a/deno/mail-relay/mail.ts b/deno/mail-relay/mail.ts
index d6dfe65..f4e3e6d 100644
--- a/deno/mail-relay/mail.ts
+++ b/deno/mail-relay/mail.ts
@@ -1,127 +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;
+export class Mail {
+ #raw;
+ #parsed;
- const match = messageIdField.match(/\<(.*?)\>/);
- if (match != null) {
- return match[1];
- } else {
- console.warn("Invalid message-id header of mail: " + messageIdField);
- return undefined;
- }
+ constructor(raw: string) {
+ this.#raw = raw;
+ this.#parsed = simpleParseMail(raw);
}
- 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;
+ get raw() {
+ return 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.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;
+ set raw(value) {
+ this.#raw = value;
+ this.#parsed = simpleParseMail(value);
}
-}
-
-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;
- }
-
- 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();
@@ -132,48 +33,23 @@ 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 {
- message: 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")
- );
- }
-
- [Symbol.for("Deno.customInspect")]() {
- return [
- `message: ${this.message}`,
- ...this.recipients
- .entries()
- .map(
- ([recipient, result]) =>
- `${recipient} [${result.kind}]: ${result.message}`,
- ),
- ].join("\n");
- }
}
export class MailDeliverContext {
@@ -190,7 +66,6 @@ export interface MailDeliverHook {
}
export abstract class MailDeliverer {
- abstract readonly name: string;
preHooks: MailDeliverHook[] = [];
postHooks: MailDeliverHook[] = [];
@@ -207,11 +82,11 @@ export abstract class MailDeliverer {
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));
+ console.info("Begin to deliver mail to...");
+
for (const hook of this.preHooks) {
await hook.callback(context);
}
@@ -222,12 +97,7 @@ export abstract class MailDeliverer {
await hook.callback(context);
}
- console.info("Deliver result:");
- console.info(context.result);
-
- if (context.result.hasError()) {
- throw new Error("Mail failed to deliver.");
- }
+ console.info("Deliver result:", context.result);
return context.result;
}
@@ -259,21 +129,16 @@ 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.",
+ "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(", "),
+ "Use recipients inferred from mail headers:",
+ [...context.recipients].join(", "),
);
}
return Promise.resolve();
@@ -285,9 +150,7 @@ export class FallbackRecipientHook implements MailDeliverHook {
callback(context: MailDeliverContext) {
if (context.recipients.size === 0) {
- console.info(
- "No recipients, fill with fallback: " + [...this.fallback].join(", "),
- );
+ console.info("Use fallback recipients:" + [...this.fallback].join(", "));
this.fallback.forEach((a) => context.recipients.add(a));
}
return Promise.resolve();
@@ -304,12 +167,13 @@ export class AliasRecipientMailHook implements MailDeliverHook {
async #parseAliasFile(): 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(`Recipient alias file ${this.#aliasFile} is not found.`);
}
return result;
}