aboutsummaryrefslogtreecommitdiff
path: root/deno/mail-relay
diff options
context:
space:
mode:
Diffstat (limited to 'deno/mail-relay')
-rw-r--r--deno/mail-relay/.gitignore3
-rw-r--r--deno/mail-relay/app.ts81
-rw-r--r--deno/mail-relay/aws/app.ts136
-rw-r--r--deno/mail-relay/aws/context.ts41
-rw-r--r--deno/mail-relay/aws/deliver.ts114
-rw-r--r--deno/mail-relay/aws/retriever.ts100
-rw-r--r--deno/mail-relay/better-js.ts14
-rw-r--r--deno/mail-relay/config.ts103
-rw-r--r--deno/mail-relay/cron.ts43
-rw-r--r--deno/mail-relay/db.test.ts23
-rw-r--r--deno/mail-relay/db.ts145
-rw-r--r--deno/mail-relay/deno.json25
-rw-r--r--deno/mail-relay/dovecot/deliver.ts102
-rw-r--r--deno/mail-relay/dumb-smtp-server.ts119
-rw-r--r--deno/mail-relay/log.ts116
-rw-r--r--deno/mail-relay/mail.test.ts143
-rw-r--r--deno/mail-relay/mail.ts340
17 files changed, 1648 insertions, 0 deletions
diff --git a/deno/mail-relay/.gitignore b/deno/mail-relay/.gitignore
new file mode 100644
index 0000000..327aef0
--- /dev/null
+++ b/deno/mail-relay/.gitignore
@@ -0,0 +1,3 @@
+out
+.env.local
+db.sqlite
diff --git a/deno/mail-relay/app.ts b/deno/mail-relay/app.ts
new file mode 100644
index 0000000..deb72c2
--- /dev/null
+++ b/deno/mail-relay/app.ts
@@ -0,0 +1,81 @@
+import { join } from "@std/path";
+import { Hono } from "hono";
+import { logger as honoLogger } from "hono/logger";
+
+import log from "./log.ts";
+import config from "./config.ts";
+import { DbService } from "./db.ts";
+import {
+ AliasRecipientMailHook,
+ FallbackRecipientHook,
+ MailDeliverer,
+} from "./mail.ts";
+import { DovecotMailDeliverer } from "./dovecot/deliver.ts";
+import { CronTask, CronTaskConfig } from "./cron.ts";
+import { DumbSmtpServer } from "./dumb-smtp-server.ts";
+
+export abstract class AppBase {
+ protected readonly db: DbService;
+ protected readonly crons: CronTask[] = [];
+ protected readonly routes: Hono[] = [];
+ protected readonly inboundDeliverer: MailDeliverer;
+ protected readonly hono = new Hono();
+
+ protected abstract readonly outboundDeliverer: MailDeliverer;
+
+ constructor() {
+ const dataPath = config.get("dataPath");
+ Deno.mkdirSync(dataPath, { recursive: true });
+ log.path = join(dataPath, "log");
+ log.info(config);
+
+ this.db = new DbService(join(dataPath, "db.sqlite"));
+ this.inboundDeliverer = new DovecotMailDeliverer();
+ this.inboundDeliverer.preHooks.push(
+ new FallbackRecipientHook(new Set(config.getList("inboundFallback"))),
+ new AliasRecipientMailHook(join(dataPath, "aliases.csv")),
+ );
+
+ this.hono.onError((err, c) => {
+ log.error(err);
+ return c.json({ msg: "Server error, check its log." }, 500);
+ });
+
+ this.hono.use(honoLogger());
+ this.hono.post("/send/raw", async (context) => {
+ const body = await context.req.text();
+ if (body.trim().length === 0) {
+ return context.json({ msg: "Can't send an empty mail." }, 400);
+ } else {
+ const result = await this.outboundDeliverer.deliverRaw(body);
+ return context.json({
+ awsMessageId: result.awsMessageId,
+ });
+ }
+ });
+ this.hono.post("/receive/raw", async (context) => {
+ await this.inboundDeliverer.deliverRaw(await context.req.text());
+ return context.json({ "msg": "Done!" });
+ });
+ }
+
+ createCron(config: CronTaskConfig): CronTask {
+ const cron = new CronTask(config);
+ this.crons.push(cron);
+ return cron;
+ }
+
+ async setup() {
+ await this.db.migrate()
+ }
+
+ serve(): { smtp: DumbSmtpServer; http: Deno.HttpServer } {
+ const smtp = new DumbSmtpServer(this.outboundDeliverer);
+ smtp.serve();
+ const http = Deno.serve({
+ hostname: config.HTTP_HOST,
+ port: config.HTTP_PORT,
+ }, this.hono.fetch);
+ return { smtp, http };
+ }
+}
diff --git a/deno/mail-relay/aws/app.ts b/deno/mail-relay/aws/app.ts
new file mode 100644
index 0000000..1fda64e
--- /dev/null
+++ b/deno/mail-relay/aws/app.ts
@@ -0,0 +1,136 @@
+import { parseArgs } from "@std/cli";
+import { z } from "zod";
+import { zValidator } from "@hono/zod-validator";
+
+import log from "../log.ts";
+import config from "../config.ts";
+import { AppBase } from "../app.ts";
+import { AwsContext } from "./context.ts";
+import {
+ AwsMailDeliverer,
+ AwsMailMessageIdRewriteHook,
+ AwsMailMessageIdSaveHook,
+} from "./deliver.ts";
+import { AwsMailRetriever } from "./retriever.ts";
+
+export class AwsRelayApp extends AppBase {
+ readonly #aws = new AwsContext();
+ readonly #retriever;
+ protected readonly outboundDeliverer = new AwsMailDeliverer(this.#aws);
+
+ constructor() {
+ super();
+ this.#retriever = new AwsMailRetriever(this.#aws, this.inboundDeliverer);
+
+ this.outboundDeliverer.preHooks.push(
+ new AwsMailMessageIdRewriteHook(this.db),
+ );
+ this.outboundDeliverer.postHooks.push(
+ new AwsMailMessageIdSaveHook(this.db),
+ );
+
+ this.hono.post(
+ `/${config.get("awsInboundPath")}`,
+ async (ctx, next) => {
+ const auth = ctx.req.header("Authorization");
+ if (auth !== config.get("awsInboundKey")) {
+ return ctx.json({ "msg": "Bad auth!" }, 403);
+ }
+ await next();
+ },
+ zValidator(
+ "json",
+ z.object({
+ key: z.string(),
+ recipients: z.optional(z.array(z.string())),
+ }),
+ ),
+ async (ctx) => {
+ const { key, recipients } = ctx.req.valid("json");
+ await this.#retriever.deliverS3Mail(key, recipients);
+ return ctx.json({ "msg": "Done!" });
+ },
+ );
+ }
+
+ realServe() {
+ this.createCron({
+ name: "live-mail-recycler",
+ interval: 6 * 3600 * 1000,
+ callback: () => {
+ return this.#retriever.recycleLiveMails();
+ },
+ startNow: true,
+ });
+
+ return this.serve();
+ }
+
+ readonly cli = {
+ "init": (_: unknown) => {
+ log.info("Just init!");
+ return Promise.resolve();
+ },
+ "list-lives": async (_: unknown) => {
+ const liveMails = await this.#retriever.listLiveMails();
+ log.info(`Total ${liveMails.length}:`);
+ log.info(liveMails.join("\n"));
+ },
+ "recycle-lives": async (_: unknown) => {
+ await this.#retriever.recycleLiveMails();
+ },
+ "serve": async (_: unknown) => {
+ await this.serve().http.finished;
+ },
+ "real-serve": async (_: unknown) => {
+ await this.realServe().http.finished;
+ },
+ } as const;
+}
+
+const nonServerCli = {
+ "sendmail": async (_: unknown) => {
+ const decoder = new TextDecoder();
+ let text = "";
+ for await (const chunk of Deno.stdin.readable) {
+ text += decoder.decode(chunk);
+ }
+
+ const res = await fetch(
+ `http://localhost:${config.HTTP_PORT}/send/raw`,
+ {
+ method: "post",
+ body: text,
+ },
+ );
+ log.infoOrError(!res.ok, res);
+ log.infoOrError(!res.ok, "Body\n" + await res.text());
+ if (!res.ok) Deno.exit(-1);
+ },
+} as const;
+
+if (import.meta.main) {
+ const args = parseArgs(Deno.args);
+
+ if (args._.length === 0) {
+ throw new Error("You must specify a command.");
+ }
+
+ const command = args._[0];
+
+ if (command in nonServerCli) {
+ log.info(`Run non-server command ${command}.`);
+ await nonServerCli[command as keyof typeof nonServerCli](args);
+ Deno.exit(0);
+ }
+
+ const app = new AwsRelayApp();
+ await app.setup();
+ if (command in app.cli) {
+ log.info(`Run command ${command}.`);
+ await app.cli[command as keyof AwsRelayApp["cli"]](args);
+ Deno.exit(0);
+ } else {
+ throw new Error(command + " is not a valid command.");
+ }
+}
diff --git a/deno/mail-relay/aws/context.ts b/deno/mail-relay/aws/context.ts
new file mode 100644
index 0000000..b1e0336
--- /dev/null
+++ b/deno/mail-relay/aws/context.ts
@@ -0,0 +1,41 @@
+import {
+ CopyObjectCommand,
+ DeleteObjectCommand,
+ S3Client,
+} from "@aws-sdk/client-s3";
+import { FetchHttpHandler } from "@smithy/fetch-http-handler";
+
+import config from "../config.ts";
+
+export class AwsContext {
+ readonly credentials = () =>
+ Promise.resolve({
+ accessKeyId: config.get("awsUser"),
+ secretAccessKey: config.get("awsPassword"),
+ });
+ readonly requestHandler = new FetchHttpHandler();
+
+ get region() {
+ return config.get("awsRegion");
+ }
+}
+
+export async function s3MoveObject(
+ client: S3Client,
+ bucket: string,
+ path: string,
+ newPath: string,
+): Promise<void> {
+ const copyCommand = new CopyObjectCommand({
+ Bucket: bucket,
+ Key: newPath,
+ CopySource: `${bucket}/${path}`,
+ });
+ await client.send(copyCommand);
+
+ const deleteCommand = new DeleteObjectCommand({
+ Bucket: bucket,
+ Key: path,
+ });
+ await client.send(deleteCommand);
+}
diff --git a/deno/mail-relay/aws/deliver.ts b/deno/mail-relay/aws/deliver.ts
new file mode 100644
index 0000000..0db5fa8
--- /dev/null
+++ b/deno/mail-relay/aws/deliver.ts
@@ -0,0 +1,114 @@
+// spellchecker: words sesv2 amazonses
+
+import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2";
+
+import log from "../log.ts";
+import { DbService } from "../db.ts";
+import {
+ Mail,
+ MailDeliverContext,
+ MailDeliverHook,
+ SyncMailDeliverer,
+} from "../mail.ts";
+import { AwsContext } from "./context.ts";
+
+declare module "../mail.ts" {
+ interface MailDeliverResult {
+ awsMessageId?: string;
+ }
+}
+
+export class AwsMailMessageIdRewriteHook implements MailDeliverHook {
+ readonly #db;
+
+ constructor(db: DbService) {
+ this.#db = db;
+ }
+
+ async callback(context: MailDeliverContext): Promise<void> {
+ log.info("Rewrite message ids...");
+ const addresses = context.mail.simpleFindAllAddresses();
+ log.info(`Addresses found in mail: ${addresses.join(", ")}.`);
+ for (const address of addresses) {
+ const awsMessageId = await this.#db.messageIdToAws(address);
+ if (awsMessageId != null && awsMessageId.length !== 0) {
+ log.info(`Rewrite ${address} to ${awsMessageId}.`);
+ context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId);
+ }
+ }
+ log.info("Done rewrite message ids.");
+ }
+}
+
+export class AwsMailMessageIdSaveHook implements MailDeliverHook {
+ readonly #db;
+
+ constructor(db: DbService) {
+ this.#db = db;
+ }
+
+ async callback(context: MailDeliverContext): Promise<void> {
+ log.info("Save aws message ids...");
+ const messageId = context.mail.startSimpleParse().sections().headers()
+ .messageId();
+ if (messageId == null) {
+ log.info("Original mail does not have message id. Skip saving.");
+ return;
+ }
+ if (context.result.awsMessageId != null) {
+ log.info(`Saving ${messageId} => ${context.result.awsMessageId}.`);
+ await this.#db.addMessageIdMap({
+ message_id: messageId,
+ aws_message_id: context.result.awsMessageId,
+ });
+ }
+ log.info("Done save message ids.");
+ }
+}
+
+export class AwsMailDeliverer extends SyncMailDeliverer {
+ readonly name = "aws";
+ readonly #aws;
+ readonly #ses;
+
+ constructor(aws: AwsContext) {
+ super();
+ this.#aws = aws;
+ this.#ses = new SESv2Client(aws);
+ }
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ log.info("Begin to call aws send-email api...");
+
+ try {
+ const sendCommand = new SendEmailCommand({
+ Content: {
+ Raw: { Data: mail.toUtf8Bytes() },
+ },
+ });
+
+ const res = await this.#ses.send(sendCommand);
+ if (res.MessageId == null) {
+ log.warn("Aws send-email returns no message id.");
+ } else {
+ context.result.awsMessageId =
+ `${res.MessageId}@${this.#aws.region}.amazonses.com`;
+ }
+
+ context.result.recipients.set("*", {
+ kind: "done",
+ message:
+ `Successfully called aws send-email, message id ${context.result.awsMessageId}.`,
+ });
+ } catch (cause) {
+ context.result.recipients.set("*", {
+ kind: "fail",
+ message: "An error was thrown when calling aws send-email." + cause,
+ cause,
+ });
+ }
+ }
+}
diff --git a/deno/mail-relay/aws/retriever.ts b/deno/mail-relay/aws/retriever.ts
new file mode 100644
index 0000000..756cfc3
--- /dev/null
+++ b/deno/mail-relay/aws/retriever.ts
@@ -0,0 +1,100 @@
+/// <reference types="npm:@types/node" />
+
+import {
+ GetObjectCommand,
+ ListObjectsV2Command,
+ S3Client,
+} from "@aws-sdk/client-s3";
+
+import log from "../log.ts";
+import config from "../config.ts";
+import "../better-js.ts";
+
+import { Mail, MailDeliverer } from "../mail.ts";
+import { AwsContext, s3MoveObject } from "./context.ts";
+
+const AWS_SES_S3_SETUP_TAG = "AMAZON_SES_SETUP_NOTIFICATION";
+
+export class AwsMailRetriever {
+ readonly liveMailPrefix = "mail/live/";
+ readonly archiveMailPrefix = "mail/archive/";
+ readonly mailBucket = config.get("awsMailBucket");
+
+ readonly #s3;
+
+ constructor(
+ aws: AwsContext,
+ public readonly inboundDeliverer: MailDeliverer,
+ ) {
+ this.#s3 = new S3Client(aws);
+ }
+
+ async listLiveMails(): Promise<string[]> {
+ log.info("Begin to retrieve live mails.");
+
+ const listCommand = new ListObjectsV2Command({
+ Bucket: this.mailBucket,
+ Prefix: this.liveMailPrefix,
+ });
+ const res = await this.#s3.send(listCommand);
+
+ if (res.Contents == null) {
+ log.warn("Listing live mails in S3 returns null Content.");
+ return [];
+ }
+
+ const result: string[] = [];
+ for (const object of res.Contents) {
+ if (object.Key == null) {
+ log.warn("Listing live mails in S3 returns an object with no Key.");
+ continue;
+ }
+
+ if (object.Key.endsWith(AWS_SES_S3_SETUP_TAG)) continue;
+
+ result.push(object.Key.slice(this.liveMailPrefix.length));
+ }
+ return result;
+ }
+
+ async deliverS3Mail(s3Key: string, recipients: string[] = []) {
+ log.info(`Begin to deliver s3 mail ${s3Key} to ${recipients.join(" ")}...`);
+
+ log.info(`Fetching s3 mail ${s3Key}...`);
+ const mailPath = `${this.liveMailPrefix}${s3Key}`;
+ const command = new GetObjectCommand({
+ Bucket: this.mailBucket,
+ Key: mailPath,
+ });
+ const res = await this.#s3.send(command);
+
+ if (res.Body == null) {
+ throw new Error("S3 mail returns a null body.");
+ }
+
+ const rawMail = await res.Body.transformToString();
+ log.info(`Done fetching s3 mail ${s3Key}.`);
+
+ log.info(`Delivering s3 mail ${s3Key}...`);
+ const mail = new Mail(rawMail);
+ await this.inboundDeliverer.deliver({ mail, recipients: recipients });
+ log.info(`Done delivering s3 mail ${s3Key}.`);
+
+ const date = mail.startSimpleParse().sections().headers().date();
+ const dateString = date?.toFileNameString(true) ?? "invalid-date";
+ const newPath = `${this.archiveMailPrefix}${dateString}/${s3Key}`;
+
+ log.info(`Archiving s3 mail ${s3Key} to ${newPath}...`);
+ await s3MoveObject(this.#s3, this.mailBucket, mailPath, newPath);
+ log.info(`Done delivering s3 mail ${s3Key}...`);
+ }
+
+ async recycleLiveMails() {
+ log.info("Begin to recycle live mails...");
+ const mails = await this.listLiveMails();
+ log.info(`Found ${mails.length} live mails`);
+ for (const s3Key of mails) {
+ await this.deliverS3Mail(s3Key);
+ }
+ }
+}
diff --git a/deno/mail-relay/better-js.ts b/deno/mail-relay/better-js.ts
new file mode 100644
index 0000000..c424a6e
--- /dev/null
+++ b/deno/mail-relay/better-js.ts
@@ -0,0 +1,14 @@
+declare global {
+ interface Date {
+ toFileNameString(dateOnly?: boolean): string;
+ }
+}
+
+Object.defineProperty(Date.prototype, "toFileNameString", {
+ value: function (this: Date, dateOnly?: boolean) {
+ const str = this.toISOString();
+ return dateOnly === true
+ ? str.slice(0, str.indexOf("T"))
+ : str.replaceAll(/:|\./g, "-");
+ },
+});
diff --git a/deno/mail-relay/config.ts b/deno/mail-relay/config.ts
new file mode 100644
index 0000000..d58b163
--- /dev/null
+++ b/deno/mail-relay/config.ts
@@ -0,0 +1,103 @@
+export const APP_PREFIX = "crupest";
+export const APP_NAME = "mail-server";
+
+export interface ConfigItemDefinition {
+ description: string;
+ default?: string;
+ secret?: boolean;
+}
+
+export const CONFIG_DEFINITIONS = {
+ mailDomain: {
+ description: "the part after `@` of an address",
+ },
+ dataPath: {
+ description: "path to save app persistent data",
+ },
+ ldaPath: {
+ description: "full path of lda executable",
+ "default": "/dovecot/libexec/dovecot/dovecot-lda",
+ },
+ inboundFallback: {
+ description: "comma separated addresses used as fallback recipients",
+ "default": "",
+ },
+ awsInboundPath: {
+ description: "(random set) path for aws sns",
+ },
+ awsInboundKey: {
+ description: "(random set) http header Authorization for aws sns",
+ },
+ awsRegion: {
+ description: "aws region",
+ },
+ awsUser: {
+ description: "aws access key id",
+ },
+ awsPassword: {
+ description: "aws secret access key",
+ secret: true,
+ },
+ awsMailBucket: {
+ description: "aws s3 bucket saving raw mails",
+ secret: true,
+ },
+} as const satisfies Record<string, ConfigItemDefinition>;
+
+type ConfigDefinitions = typeof CONFIG_DEFINITIONS;
+type ConfigNames = keyof ConfigDefinitions;
+type ConfigMap = {
+ [K in ConfigNames]: ConfigDefinitions[K] & {
+ readonly env: string;
+ readonly value: string;
+ };
+};
+
+function resolveConfig(): ConfigMap {
+ const result: Record<string, ConfigMap[ConfigNames]> = {};
+ for (const [name, def] of Object.entries(CONFIG_DEFINITIONS)) {
+ const env = `${APP_PREFIX}-${APP_NAME}-${
+ name.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())
+ }`.replaceAll("-", "_").toUpperCase();
+ const value = Deno.env.get(env) ?? (def as ConfigItemDefinition).default;
+ if (value == null) {
+ throw new Error(`Required env ${env} (${def.description}) is not set.`);
+ }
+ result[name] = { ...def, env, value };
+ }
+ return result as ConfigMap;
+}
+
+export class Config {
+ #config = resolveConfig();
+
+ readonly HTTP_HOST = "0.0.0.0";
+ readonly HTTP_PORT = 2345;
+ readonly SMTP_HOST = "127.0.0.1";
+ readonly SMTP_PORT = 2346;
+
+ getAllConfig<K extends ConfigNames>(key: K): ConfigMap[K] {
+ return this.#config[key];
+ }
+
+ get(key: ConfigNames): string {
+ return this.getAllConfig(key).value;
+ }
+
+ getList(key: ConfigNames, separator: string = ","): string[] {
+ const value = this.get(key);
+ if (value.length === 0) return [];
+ return value.split(separator);
+ }
+
+ [Symbol.for("Deno.customInspect")]() {
+ return Object.entries(this.#config).map(([key, item]) =>
+ `${key} [env: ${item.env}]: ${
+ (item as ConfigItemDefinition).secret === true ? "***" : item.value
+ }`
+ ).join("\n");
+ }
+}
+
+const config = new Config();
+export default config;
diff --git a/deno/mail-relay/cron.ts b/deno/mail-relay/cron.ts
new file mode 100644
index 0000000..bf0a0be
--- /dev/null
+++ b/deno/mail-relay/cron.ts
@@ -0,0 +1,43 @@
+export type CronCallback = (task: CronTask) => Promise<void>;
+
+export interface CronTaskConfig {
+ readonly name: string;
+ readonly interval: number;
+ readonly callback: CronCallback;
+ readonly startNow?: boolean;
+}
+
+export class CronTask {
+ #timerTag: number | null = null;
+
+ constructor(public readonly config: CronTaskConfig) {
+ if (config.interval <= 0) {
+ throw new Error("Cron task interval must be positive.");
+ }
+
+ if (config.startNow === true) {
+ this.start();
+ }
+ }
+
+ get running(): boolean {
+ return this.#timerTag != null;
+ }
+
+ start() {
+ if (this.#timerTag == null) {
+ this.#timerTag = setInterval(
+ this.config.callback,
+ this.config.interval,
+ this,
+ );
+ }
+ }
+
+ stop() {
+ if (this.#timerTag != null) {
+ clearInterval(this.#timerTag);
+ this.#timerTag = null;
+ }
+ }
+}
diff --git a/deno/mail-relay/db.test.ts b/deno/mail-relay/db.test.ts
new file mode 100644
index 0000000..60035c4
--- /dev/null
+++ b/deno/mail-relay/db.test.ts
@@ -0,0 +1,23 @@
+import { describe, it } from "@std/testing/bdd";
+import { expect } from "@std/expect/expect";
+
+import { DbService } from "./db.ts";
+
+describe("DbService", () => {
+ const mockRow = {
+ message_id: "mock-message-id@mock.mock",
+ aws_message_id: "mock-aws-message-id@mock.mock",
+ };
+
+ it("works", async () => {
+ const db = new DbService(":memory:");
+ await db.migrate();
+ await db.addMessageIdMap(mockRow);
+ expect(await db.messageIdToAws(mockRow.message_id)).toBe(
+ mockRow.aws_message_id,
+ );
+ expect(await db.messageIdFromAws(mockRow.aws_message_id)).toBe(
+ mockRow.message_id,
+ );
+ });
+});
diff --git a/deno/mail-relay/db.ts b/deno/mail-relay/db.ts
new file mode 100644
index 0000000..9b05e32
--- /dev/null
+++ b/deno/mail-relay/db.ts
@@ -0,0 +1,145 @@
+// spellchecker: words kysely insertable updateable introspector
+
+import {
+ Generated,
+ Insertable,
+ Kysely,
+ Migration,
+ Migrator,
+ SqliteDatabase,
+ SqliteDialect,
+ SqliteStatement,
+} from "kysely";
+import * as sqlite from "@db/sqlite";
+
+class SqliteStatementAdapter implements SqliteStatement {
+ constructor(public readonly stmt: sqlite.Statement) {}
+
+ get reader(): boolean {
+ return this.stmt.columnNames().length >= 1;
+ }
+
+ all(parameters: readonly unknown[]): unknown[] {
+ return this.stmt.all(...parameters as sqlite.BindValue[]);
+ }
+
+ iterate(parameters: readonly unknown[]): IterableIterator<unknown> {
+ return this.stmt.iter(...parameters as sqlite.BindValue[]);
+ }
+
+ run(
+ parameters: readonly unknown[],
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
+ const { db } = this.stmt;
+ const totalChangesBefore = db.totalChanges;
+ const changes = this.stmt.run(...parameters as sqlite.BindValue[]);
+ return {
+ changes: totalChangesBefore === db.totalChanges ? 0 : changes,
+ lastInsertRowid: db.lastInsertRowId,
+ };
+ }
+}
+
+class SqliteDatabaseAdapter implements SqliteDatabase {
+ constructor(public readonly db: sqlite.Database) {}
+
+ prepare(sql: string): SqliteStatementAdapter {
+ return new SqliteStatementAdapter(this.db.prepare(sql));
+ }
+
+ close(): void {
+ this.db.close();
+ }
+}
+
+export class DbError extends Error {
+}
+
+interface AwsMessageIdMapTable {
+ id: Generated<number>;
+ message_id: string;
+ aws_message_id: string;
+}
+
+interface Database {
+ aws_message_id_map: AwsMessageIdMapTable;
+}
+
+const migrations: Record<string, Migration> = {
+ "0001-init": {
+ // deno-lint-ignore no-explicit-any
+ async up(db: Kysely<any>): Promise<void> {
+ await db.schema
+ .createTable("aws_message_id_map")
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
+ .addColumn("message_id", "text", (col) => col.notNull().unique())
+ .addColumn("aws_message_id", "text", (col) => col.notNull().unique())
+ .execute();
+
+ for (const column of ["message_id", "aws_message_id"]) {
+ await db.schema
+ .createIndex(`aws_message_id_map_${column}`)
+ .on("aws_message_id_map")
+ .column(column)
+ .execute();
+ }
+ },
+
+ // deno-lint-ignore no-explicit-any
+ async down(db: Kysely<any>): Promise<void> {
+ await db.schema.dropTable("aws_message_id_map").execute();
+ },
+ },
+};
+
+export class DbService {
+ #db;
+ #kysely;
+ #migrator;
+
+ constructor(public readonly path: string) {
+ this.#db = new sqlite.Database(path);
+ this.#kysely = new Kysely<Database>({
+ dialect: new SqliteDialect(
+ { database: new SqliteDatabaseAdapter(this.#db) },
+ ),
+ });
+ this.#migrator = new Migrator({
+ db: this.#kysely,
+ provider: {
+ getMigrations(): Promise<Record<string, Migration>> {
+ return Promise.resolve(migrations);
+ },
+ },
+ });
+ }
+
+ async migrate(): Promise<void> {
+ await this.#migrator.migrateToLatest();
+ }
+
+ async addMessageIdMap(
+ mail: Insertable<AwsMessageIdMapTable>,
+ ): Promise<number> {
+ const inserted = await this.#kysely.insertInto("aws_message_id_map").values(
+ mail,
+ ).executeTakeFirstOrThrow();
+ return Number(inserted.insertId!);
+ }
+
+ async messageIdToAws(messageId: string): Promise<string | null> {
+ const row = await this.#kysely.selectFrom("aws_message_id_map").where(
+ "message_id",
+ "=",
+ messageId,
+ ).select("aws_message_id").executeTakeFirst();
+ return row?.aws_message_id ?? null;
+ }
+
+ async messageIdFromAws(awsMessageId: string): Promise<string | null> {
+ const row = await this.#kysely.selectFrom("aws_message_id_map")
+ .where("aws_message_id", "=", awsMessageId).select("message_id")
+ .executeTakeFirst();
+ return row?.message_id ?? null;
+ }
+}
diff --git a/deno/mail-relay/deno.json b/deno/mail-relay/deno.json
new file mode 100644
index 0000000..9066b33
--- /dev/null
+++ b/deno/mail-relay/deno.json
@@ -0,0 +1,25 @@
+{
+ "tasks": {
+ "run": "deno run -A aws/app.ts",
+ "test": "deno test -A",
+ "compile": "deno compile -o out/crupest-relay -A aws/app.ts"
+ },
+ "imports": {
+ "@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.821.0",
+ "@aws-sdk/client-sesv2": "npm:@aws-sdk/client-sesv2@^3.821.0",
+ "@db/sqlite": "jsr:@db/sqlite@^0.12.0",
+ "@hono/zod-validator": "npm:@hono/zod-validator@^0.7.0",
+ "@smithy/fetch-http-handler": "npm:@smithy/fetch-http-handler@^5.0.4",
+ "@std/cli": "jsr:@std/cli@^1.0.19",
+ "@std/csv": "jsr:@std/csv@^1.0.6",
+ "@std/encoding": "jsr:@std/encoding@^1.0.10",
+ "@std/expect": "jsr:@std/expect@^1.0.16",
+ "@std/io": "jsr:@std/io@^0.225.2",
+ "@std/path": "jsr:@std/path@^1.1.0",
+ "@std/testing": "jsr:@std/testing@^1.0.13",
+ "email-addresses": "npm:email-addresses@^5.0.0",
+ "hono": "npm:hono@^4.7.11",
+ "kysely": "npm:kysely@^0.28.2",
+ "zod": "npm:zod@^3.25.48"
+ }
+}
diff --git a/deno/mail-relay/dovecot/deliver.ts b/deno/mail-relay/dovecot/deliver.ts
new file mode 100644
index 0000000..92bdc58
--- /dev/null
+++ b/deno/mail-relay/dovecot/deliver.ts
@@ -0,0 +1,102 @@
+import { basename } from "@std/path";
+
+import config from "../config.ts";
+import log from "../log.ts";
+import {
+ Mail,
+ MailDeliverContext,
+ MailDeliverer,
+ RecipientFromHeadersHook,
+} from "../mail.ts";
+
+export class DovecotMailDeliverer extends MailDeliverer {
+ readonly name = "dovecot";
+
+ constructor() {
+ super();
+ this.preHooks.push(
+ new RecipientFromHeadersHook(),
+ );
+ }
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ const ldaPath = config.get("ldaPath");
+ const ldaBinName = basename(ldaPath);
+ const utf8Stream = mail.toUtf8Bytes();
+
+ const recipients = [...context.recipients];
+
+ if (recipients.length === 0) {
+ context.result.message =
+ "Failed to deliver to dovecot, no recipients are specified.";
+ return;
+ }
+
+ log.info(`Deliver to dovecot users: ${recipients.join(", ")}.`);
+
+ for (const recipient of recipients) {
+ try {
+ const commandArgs = ["-d", recipient];
+ log.info(
+ `Run ${ldaBinName} ${commandArgs.join(" ")}...`,
+ );
+
+ const ldaCommand = new Deno.Command(ldaPath, {
+ args: commandArgs,
+ stdin: "piped",
+ stdout: "piped",
+ stderr: "piped",
+ });
+
+ const ldaProcess = ldaCommand.spawn();
+ using logFiles = await log.openLogForProgram(ldaBinName);
+ ldaProcess.stdout.pipeTo(logFiles.stdout.writable);
+ ldaProcess.stderr.pipeTo(logFiles.stderr.writable);
+
+ 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) {
+ context.result.recipients.set(recipient, {
+ kind: "fail",
+ message: "An error is thrown when running lda: " + cause,
+ cause,
+ });
+ }
+ }
+
+ log.info("Done handling all recipients.");
+ }
+}
diff --git a/deno/mail-relay/dumb-smtp-server.ts b/deno/mail-relay/dumb-smtp-server.ts
new file mode 100644
index 0000000..6c63f5c
--- /dev/null
+++ b/deno/mail-relay/dumb-smtp-server.ts
@@ -0,0 +1,119 @@
+import config from "./config.ts";
+import log from "./log.ts";
+import { MailDeliverer } from "./mail.ts";
+
+const CRLF = "\r\n";
+
+const SERVER_NAME = `[${config.SMTP_HOST}]:${config.SMTP_PORT}`;
+
+const RESPONSES = {
+ "READY": `220 ${SERVER_NAME} SMTP Ready`,
+ "EHLO": `250 ${SERVER_NAME}`,
+ "MAIL": "250 2.1.0 Sender OK",
+ "RCPT": "250 2.1.5 Recipient OK",
+ "DATA": "354 Start mail input; end with <CRLF>.<CRLF>",
+ "QUIT": `211 2.0.0 ${SERVER_NAME} closing connection`,
+ "INVALID": "500 5.5.1 Error: command not recognized",
+} as const;
+
+export class DumbSmtpServer {
+ #deliverer: MailDeliverer;
+
+ constructor(deliverer: MailDeliverer) {
+ this.#deliverer = deliverer;
+ }
+
+ async #handleConnection(conn: Deno.Conn) {
+ using disposeStack = new DisposableStack();
+ disposeStack.defer(() => {
+ log.info("Close smtp session tcp connection.");
+ conn.close();
+ });
+ const writer = conn.writable.getWriter();
+ disposeStack.defer(() => writer.releaseLock());
+ const reader = conn.readable.getReader();
+ disposeStack.defer(() => reader.releaseLock());
+
+ const [decoder, encoder] = [new TextDecoder(), new TextEncoder()];
+ const decode = (data: Uint8Array) => decoder.decode(data);
+ const send = async (s: string) =>
+ await writer.write(encoder.encode(s + CRLF));
+
+ let buffer: string = "";
+ let rawMail: string | null = null;
+
+ await send(RESPONSES["READY"]);
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+
+ buffer += decode(value);
+
+ while (true) {
+ const eolPos = buffer.indexOf(CRLF);
+ if (eolPos === -1) break;
+
+ const line = buffer.slice(0, eolPos);
+ buffer = buffer.slice(eolPos + CRLF.length);
+
+ if (rawMail == null) {
+ log.info("Smtp server received line:", line);
+ const upperLine = line.toUpperCase();
+ if (upperLine.startsWith("EHLO") || upperLine.startsWith("HELO")) {
+ await send(RESPONSES["EHLO"]);
+ } else if (upperLine.startsWith("MAIL FROM:")) {
+ await send(RESPONSES["MAIL"]);
+ } else if (upperLine.startsWith("RCPT TO:")) {
+ await send(RESPONSES["RCPT"]);
+ } else if (upperLine === "DATA") {
+ await send(RESPONSES["DATA"]);
+ log.info("Begin to receive mail data...");
+ rawMail = "";
+ } else if (upperLine === "QUIT") {
+ await send(RESPONSES["QUIT"]);
+ return;
+ } else {
+ log.warn("Smtp server command unrecognized:", line);
+ await send(RESPONSES["INVALID"]);
+ return;
+ }
+ } else {
+ if (line === ".") {
+ try {
+ log.info("Done receiving mail data, begin to relay...");
+ const { message } = await this.#deliverer.deliverRaw(rawMail);
+ await send(`250 2.6.0 ${message}`);
+ rawMail = null;
+ log.info("Done SMTP mail session.");
+ } catch (err) {
+ log.info(err);
+ await send("554 5.3.0 Error: check server log");
+ return;
+ }
+ } else {
+ const dataLine = line.startsWith("..") ? line.slice(1) : line;
+ rawMail += dataLine + CRLF;
+ }
+ }
+ }
+ }
+ }
+
+ async serve() {
+ const listener = Deno.listen({
+ hostname: config.SMTP_HOST,
+ port: config.SMTP_PORT,
+ });
+ listener.unref();
+ log.info(`Dumb SMTP server starts running on port ${config.SMTP_PORT}.`);
+
+ for await (const conn of listener) {
+ try {
+ await this.#handleConnection(conn);
+ } catch (cause) {
+ log.error("One smtp connection session throws an error " + cause);
+ }
+ }
+ }
+}
diff --git a/deno/mail-relay/log.ts b/deno/mail-relay/log.ts
new file mode 100644
index 0000000..ce27eca
--- /dev/null
+++ b/deno/mail-relay/log.ts
@@ -0,0 +1,116 @@
+import { join } from "@std/path";
+import { toWritableStream, Writer } from "@std/io";
+
+import "./better-js.ts";
+
+export interface LogOptions {
+ time?: Date;
+ error?: boolean;
+}
+
+export type LogFile = Pick<Deno.FsFile, "writable"> & Disposable;
+
+export class Log {
+ #path: string | null = null;
+
+ #wrapWriter(writer: Writer): LogFile {
+ return {
+ writable: toWritableStream(writer, { autoClose: false }),
+ [Symbol.dispose]() {},
+ };
+ }
+
+ #stdoutWrapper: LogFile = this.#wrapWriter(Deno.stdout);
+ #stderrWrapper: LogFile = this.#wrapWriter(Deno.stderr);
+
+ constructor() {
+ }
+
+ get path() {
+ return this.#path;
+ }
+
+ set path(path) {
+ this.#path = path;
+ if (path != null) {
+ Deno.mkdirSync(path, { recursive: true });
+ }
+ }
+
+ infoOrError(isError: boolean, ...args: unknown[]) {
+ this[isError ? "error" : "info"].call(this, ...args);
+ }
+
+ info(...args: unknown[]) {
+ console.log(...args);
+ }
+
+ warn(...args: unknown[]) {
+ console.warn(...args);
+ }
+
+ error(...args: unknown[]) {
+ console.error(...args);
+ }
+
+ #extractOptions(options?: LogOptions): Required<LogOptions> {
+ return {
+ time: options?.time ?? new Date(),
+ error: options?.error ?? false,
+ };
+ }
+
+ async openLog(
+ prefix: string,
+ suffix: string,
+ options?: LogOptions,
+ ): Promise<LogFile> {
+ if (prefix.includes("/")) {
+ throw new Error(`Log file prefix ${prefix} contains '/'.`);
+ }
+ if (suffix.includes("/")) {
+ throw new Error(`Log file suffix ${suffix} contains '/'.`);
+ }
+
+ const { time, error } = this.#extractOptions(options);
+ if (this.#path == null) {
+ return error ? this.#stderrWrapper : this.#stdoutWrapper;
+ }
+
+ const logPath = join(
+ this.#path,
+ `${prefix}-${time.toFileNameString()}-${suffix}`,
+ );
+ return await Deno.open(logPath, {
+ read: false,
+ write: true,
+ append: true,
+ create: true,
+ });
+ }
+
+ async openLogForProgram(
+ program: string,
+ options?: Omit<LogOptions, "error">,
+ ): Promise<{ stdout: LogFile; stderr: LogFile } & Disposable> {
+ const stdout = await this.openLog(program, "stdout", {
+ ...options,
+ error: false,
+ });
+ const stderr = await this.openLog(program, "stderr", {
+ ...options,
+ error: true,
+ });
+ return {
+ stdout,
+ stderr,
+ [Symbol.dispose]: () => {
+ stdout[Symbol.dispose]();
+ stderr[Symbol.dispose]();
+ },
+ };
+ }
+}
+
+const log = new Log();
+export default log;
diff --git a/deno/mail-relay/mail.test.ts b/deno/mail-relay/mail.test.ts
new file mode 100644
index 0000000..ee275af
--- /dev/null
+++ b/deno/mail-relay/mail.test.ts
@@ -0,0 +1,143 @@
+import { describe, it } from "@std/testing/bdd";
+import { expect, fn } from "@std/expect";
+
+import { Mail, MailDeliverContext, MailDeliverer } from "./mail.ts";
+
+const mockDate = "Fri, 02 May 2025 08:33:02 +0000";
+const mockMessageId = "mock-message-id@from.mock";
+const mockMessageId2 = "mock-message-id-2@from.mock";
+const mockFromAddress = "mock@from.mock";
+const mockCcAddress = "mock@cc.mock";
+const mockBodyStr = `This is body content.
+Line 2 ${mockMessageId2}
+
+Line 4`;
+const mockHeaders = [
+ ["Content-Disposition", "inline"],
+ ["Content-Transfer-Encoding", "quoted-printable"],
+ ["MIME-Version", "1.0"],
+ ["X-Mailer", "MIME-tools 5.509 (Entity 5.509)"],
+ ["Content-Type", "text/plain; charset=utf-8"],
+ ["From", `"Mock From" <${mockFromAddress}>`],
+ [
+ "To",
+ `"John \\"Big\\" Doe" <john@example.com>, "Alice (Work)" <alice+work@example.com>,
+ undisclosed-recipients:;, "Group: Team" <team@company.com>,
+ "Escaped, Name" <escape@test.com>, just@email.com,
+ "Comment (This is valid)" <comment@domain.net>,
+ "Odd @Chars" <weird!#$%'*+-/=?^_\`{|}~@char-test.com>,
+ "Non-ASCII 用户" <user@例子.中国>,
+ admin@[192.168.1.1]`,
+ ],
+ ["CC", `Mock CC <${mockCcAddress}>`],
+ ["Subject", "A very long mock\n subject"],
+ ["Message-ID", `<${mockMessageId}>`],
+ ["Date", mockDate],
+];
+const mockHeaderStr = mockHeaders.map((h) => h[0] + ": " + h[1]).join("\n");
+const mockMailStr = mockHeaderStr + "\n\n" + mockBodyStr;
+const mockCrlfMailStr = mockMailStr.replaceAll("\n", "\r\n");
+const mockToAddresses = [
+ "john@example.com",
+ "alice+work@example.com",
+ "team@company.com",
+ "escape@test.com",
+ "just@email.com",
+ "comment@domain.net",
+ "weird!#$%'*+-/=?^_`{|}~@char-test.com",
+ "user@例子.中国",
+ "admin@[192.168.1.1]",
+];
+
+describe("Mail", () => {
+ it("simple parse", () => {
+ const parsed = new Mail(mockMailStr).startSimpleParse().sections();
+ expect(parsed.header).toEqual(mockHeaderStr);
+ expect(parsed.body).toEqual(mockBodyStr);
+ expect(parsed.sep).toBe("\n");
+ expect(parsed.eol).toBe("\n");
+ });
+
+ it("simple parse crlf", () => {
+ const parsed = new Mail(mockCrlfMailStr).startSimpleParse().sections();
+ expect(parsed.sep).toBe("\r\n");
+ expect(parsed.eol).toBe("\r\n");
+ });
+
+ it("simple parse date", () => {
+ expect(new Mail(mockMailStr).startSimpleParse().sections().headers().date())
+ .toEqual(new Date(mockDate));
+ });
+
+ it("simple parse headers", () => {
+ expect(
+ new Mail(mockMailStr).startSimpleParse().sections().headers(),
+ ).toEqual(mockHeaders.map(
+ (h) => [h[0], " " + h[1].replaceAll("\n", "")],
+ ));
+ });
+
+ it("append headers", () => {
+ const mail = new Mail(mockMailStr);
+ const mockMoreHeaders = [["abc", "123"], ["def", "456"]] satisfies [
+ string,
+ string,
+ ][];
+ mail.appendHeaders(mockMoreHeaders);
+
+ expect(mail.raw).toBe(
+ mockHeaderStr + "\n" +
+ mockMoreHeaders.map((h) => h[0] + ": " + h[1]).join("\n") +
+ "\n\n" + mockBodyStr,
+ );
+ });
+
+ 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")));
+ });
+
+ it("find all addresses", () => {
+ const mail = new Mail(mockMailStr);
+ expect(mail.simpleFindAllAddresses()).toEqual([
+ "mock@from.mock",
+ "john@example.com",
+ "alice+work@example.com",
+ "team@company.com",
+ "escape@test.com",
+ "just@email.com",
+ "comment@domain.net",
+ "mock@cc.mock",
+ "mock-message-id@from.mock",
+ "mock-message-id-2@from.mock",
+ ]);
+ });
+});
+
+describe("MailDeliverer", () => {
+ class MockMailDeliverer extends MailDeliverer {
+ name = "mock";
+ override doDeliver = fn((_: Mail, ctx: MailDeliverContext) => {
+ ctx.result.recipients.set("*", { kind: "done", message: "success" });
+ return Promise.resolve();
+ }) as MailDeliverer["doDeliver"];
+ }
+ const mockDeliverer = new MockMailDeliverer();
+
+ it("deliver success", async () => {
+ await mockDeliverer.deliverRaw(mockMailStr);
+ expect(mockDeliverer.doDeliver).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/deno/mail-relay/mail.ts b/deno/mail-relay/mail.ts
new file mode 100644
index 0000000..af0df40
--- /dev/null
+++ b/deno/mail-relay/mail.ts
@@ -0,0 +1,340 @@
+import { encodeBase64 } from "@std/encoding/base64";
+import { parse } from "@std/csv/parse";
+import emailAddresses from "email-addresses";
+
+import log from "./log.ts";
+import config from "./config.ts";
+
+class MailSimpleParseError extends Error {
+ constructor(
+ message: string,
+ public readonly text: string,
+ public readonly lineNumber?: number,
+ options?: ErrorOptions,
+ ) {
+ if (lineNumber != null) message += `(at line ${lineNumber})`;
+ super(message, options);
+ }
+}
+
+class MailSimpleParsedHeaders extends Array<[key: string, value: string]> {
+ getFirst(fieldKey: string): string | undefined {
+ for (const [key, value] of this) {
+ 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())) {
+ log.warn(`Invalid date string (${dateField}) found in header.`);
+ return undefined;
+ }
+ return date;
+ }
+
+ 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;
+
+ 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.",
+ raw,
+ );
+ }
+
+ const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]];
+
+ if (eol !== sep) {
+ log.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 = new MailSimpleParsedHeaders();
+
+ 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 field.",
+ this.header,
+ lineNumber,
+ );
+ }
+ 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 field starts with a space.",
+ this.header,
+ lineNumber,
+ );
+ }
+ field += line;
+ } else {
+ handleField();
+ field = line;
+ }
+ lineNumber += 1;
+ }
+
+ handleField();
+
+ return headers;
+ }
+}
+
+export class Mail {
+ constructor(public raw: string) {}
+
+ toUtf8Bytes(): Uint8Array {
+ const utf8Encoder = new TextEncoder();
+ return utf8Encoder.encode(this.raw);
+ }
+
+ toBase64(): string {
+ return encodeBase64(this.raw);
+ }
+
+ startSimpleParse() {
+ return { sections: () => new MailSimpleParsedSections(this.raw) };
+ }
+
+ simpleFindAllAddresses(): string[] {
+ const re = /,?\<?([a-z0-9_'+\-\.]+\@[a-z0-9_'+\-\.]+)\>?,?/ig
+ return [...this.raw.matchAll(re)].map(m => m[1])
+ }
+
+ // TODO: Add folding.
+ appendHeaders(headers: [key: string, value: string][]) {
+ const { header, body, sep, eol } = this.startSimpleParse().sections();
+
+ this.raw = header + eol +
+ headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep +
+ body;
+ }
+}
+
+export type MailDeliverResultKind = "done" | "fail";
+
+export interface MailDeliverRecipientResult {
+ kind: MailDeliverResultKind;
+ message: string;
+ cause?: unknown;
+}
+
+export class MailDeliverResult {
+ message: string = "";
+ recipients: Map<string, MailDeliverRecipientResult> = new Map();
+
+ 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 {
+ readonly recipients: Set<string> = new Set();
+ readonly result;
+
+ constructor(public mail: Mail) {
+ this.result = new MailDeliverResult(this.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,
+ context: MailDeliverContext,
+ ): Promise<void>;
+
+ async deliverRaw(rawMail: string) {
+ return await this.deliver({ mail: new Mail(rawMail) });
+ }
+
+ async deliver(
+ options: { mail: Mail; recipients?: string[] },
+ ): Promise<MailDeliverResult> {
+ log.info(`Begin to deliver mail via ${this.name}...`);
+
+ const context = new MailDeliverContext(options.mail);
+ options.recipients?.forEach((r) => context.recipients.add(r));
+
+ for (const hook of this.preHooks) {
+ await hook.callback(context);
+ }
+
+ await this.doDeliver(context.mail, context);
+
+ for (const hook of this.postHooks) {
+ await hook.callback(context);
+ }
+
+ log.info("Deliver result:");
+ log.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: { mail: Mail; recipients?: string[] },
+ ): Promise<MailDeliverResult> {
+ log.info("The mail deliverer is sync. Wait for last delivering done...");
+ await this.#last;
+ const result = super.deliver(options);
+ this.#last = result.then(() => {}, () => {});
+ return result;
+ }
+}
+
+export class RecipientFromHeadersHook implements MailDeliverHook {
+ callback(context: MailDeliverContext) {
+ if (context.recipients.size !== 0) {
+ log.warn(
+ "Recipients are already filled. Won't set them with ones in headers.",
+ );
+ } else {
+ context.mail.startSimpleParse().sections().headers().recipients({
+ domain: config.get("mailDomain"),
+ }).forEach((r) => context.recipients.add(r));
+
+ log.info(
+ "Recipients found from mail headers: ",
+ [...context.recipients].join(" "),
+ );
+ }
+ return Promise.resolve();
+ }
+}
+
+export class FallbackRecipientHook implements MailDeliverHook {
+ constructor(public fallback: Set<string> = new Set()) {}
+
+ callback(context: MailDeliverContext) {
+ 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 AliasRecipientMailHook 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.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));
+ }
+ }
+ return result;
+ }
+
+ async callback(context: MailDeliverContext) {
+ const aliases = await this.#parseAliasFile();
+ 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);
+ }
+ }
+ }
+}