aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuqian Yang <crupest@crupest.life>2025-06-17 19:04:16 +0800
committerYuqian Yang <crupest@crupest.life>2025-06-18 16:24:32 +0800
commit9abee12c8429141c80cf4ab9e428d3e361fc9bb3 (patch)
tree0551ae2b33929b10b2d293232c5490abf6f77a5f
parent0824de3bae3550674a9ea029b03c5cb8a35cd8e1 (diff)
downloadcrupest-9abee12c8429141c80cf4ab9e428d3e361fc9bb3.tar.gz
crupest-9abee12c8429141c80cf4ab9e428d3e361fc9bb3.tar.bz2
crupest-9abee12c8429141c80cf4ab9e428d3e361fc9bb3.zip
mail: simplify mail parsing, improve logging.mail
-rw-r--r--deno/mail-relay/app.ts2
-rw-r--r--deno/mail-relay/aws/app.ts5
-rw-r--r--deno/mail-relay/aws/deliver.ts25
-rw-r--r--deno/mail-relay/aws/fetch.ts40
-rw-r--r--deno/mail-relay/aws/mail.ts26
-rw-r--r--deno/mail-relay/dovecot.ts107
-rw-r--r--deno/mail-relay/dumb-smtp-server.ts56
-rw-r--r--deno/mail-relay/mail-parsing.ts144
-rw-r--r--deno/mail-relay/mail.test.ts31
-rw-r--r--deno/mail-relay/mail.ts284
10 files changed, 382 insertions, 338 deletions
diff --git a/deno/mail-relay/app.ts b/deno/mail-relay/app.ts
index bb35378..af328da 100644
--- a/deno/mail-relay/app.ts
+++ b/deno/mail-relay/app.ts
@@ -38,7 +38,7 @@ export function createHono(outbound: MailDeliverer, inbound: MailDeliverer) {
const hono = new Hono();
hono.onError((err, c) => {
- console.error("Hono handler throws an error.", err);
+ console.error("Hono handler threw an uncaught error.", err);
return c.json({ msg: "Server error, check its log." }, 500);
});
hono.use(honoLogger());
diff --git a/deno/mail-relay/aws/app.ts b/deno/mail-relay/aws/app.ts
index a8a9895..c8a90c8 100644
--- a/deno/mail-relay/aws/app.ts
+++ b/deno/mail-relay/aws/app.ts
@@ -110,7 +110,7 @@ function createOutbound(
new AwsMailMessageIdSaveHook(
async (original, aws, context) => {
await db.addMessageIdMap({ message_id: original, aws_message_id: aws });
- void local?.saveNewSent(context.mail, original);
+ void local?.saveNewSent(context.logTag, context.mail, original);
},
),
);
@@ -213,11 +213,14 @@ function createServerServices() {
const smtp = createSmtp(outbound);
const hono = createHono(outbound, inbound);
+
+ let counter = 1;
setupAwsHono(hono, {
path: config.get("awsInboundPath"),
auth: config.get("awsInboundKey"),
callback: (s3Key, recipients) => {
return fetcher.consumeS3Mail(
+ `[inbound ${counter++}]`,
s3Key,
(rawMail, _) =>
inbound.deliver({ mail: new Mail(rawMail), recipients }).then(),
diff --git a/deno/mail-relay/aws/deliver.ts b/deno/mail-relay/aws/deliver.ts
index ae010a7..0195369 100644
--- a/deno/mail-relay/aws/deliver.ts
+++ b/deno/mail-relay/aws/deliver.ts
@@ -4,7 +4,7 @@ import {
SESv2ClientConfig,
} from "@aws-sdk/client-sesv2";
-import { Mail, MailDeliverContext, SyncMailDeliverer } from "../mail.ts";
+import { Mail, MailDeliverContext, MailDeliverer } from "../mail.ts";
declare module "../mail.ts" {
interface MailDeliverResult {
@@ -12,13 +12,13 @@ declare module "../mail.ts" {
}
}
-export class AwsMailDeliverer extends SyncMailDeliverer {
+export class AwsMailDeliverer extends MailDeliverer {
readonly name = "aws";
readonly #aws;
readonly #ses;
constructor(aws: SESv2ClientConfig) {
- super();
+ super(true);
this.#aws = aws;
this.#ses = new SESv2Client(aws);
}
@@ -27,8 +27,6 @@ export class AwsMailDeliverer extends SyncMailDeliverer {
mail: Mail,
context: MailDeliverContext,
): Promise<void> {
- console.info("Begin to call aws send-email api...");
-
try {
const sendCommand = new SendEmailCommand({
Content: {
@@ -36,23 +34,28 @@ export class AwsMailDeliverer extends SyncMailDeliverer {
},
});
+ console.info(context.logTag, "Calling aws send-email api...");
const res = await this.#ses.send(sendCommand);
if (res.MessageId == null) {
- console.warn("Aws send-email returns no message id.");
+ console.warn(
+ context.logTag,
+ "AWS send-email returned null message id.",
+ );
} else {
context.result.awsMessageId =
`${res.MessageId}@${this.#aws.region}.amazonses.com`;
}
- context.result.smtpMessage = `AWS Message ID: ${context.result.awsMessageId}`;
+ context.result.smtpMessage =
+ `AWS Message ID: ${context.result.awsMessageId}`;
context.result.recipients.set("*", {
- kind: "done",
- message: `Successfully called aws send-email.`,
+ 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..c603a35 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,
@@ -59,14 +57,14 @@ export class AwsMailFetcher {
const res = await this.#s3.send(listCommand);
if (res.Contents == null) {
- console.warn("Listing live mails in S3 returns null Content.");
+ console.warn("S3 API returned null Content.");
return [];
}
const result: string[] = [];
for (const object of res.Contents) {
if (object.Key == null) {
- console.warn("Listing live mails in S3 returns an object with no Key.");
+ console.warn("S3 API returned null Key.");
continue;
}
@@ -77,10 +75,12 @@ export class AwsMailFetcher {
return result;
}
- async consumeS3Mail(s3Key: string, consumer: AwsS3MailConsumer) {
- console.info(`Begin to consume s3 mail ${s3Key} ...`);
-
- console.info(`Fetching s3 mail ${s3Key}...`);
+ async consumeS3Mail(
+ logTag: string,
+ s3Key: string,
+ consumer: AwsS3MailConsumer,
+ ) {
+ console.info(logTag, `Fetching s3 mail ${s3Key}...`);
const mailPath = `${this.#livePrefix}${s3Key}`;
const command = new GetObjectCommand({
Bucket: this.#bucket,
@@ -89,39 +89,37 @@ export class AwsMailFetcher {
const res = await this.#s3.send(command);
if (res.Body == null) {
- throw new Error("S3 mail returns a null body.");
+ throw new Error("S3 API returns a null body.");
}
const rawMail = await res.Body.transformToString();
- console.info(`Done fetching s3 mail ${s3Key}.`);
- console.info(`Calling consumer...`);
+ console.info(logTag, `Calling consumer...`);
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";
const newPath = `${this.#archivePrefix}${dateString}/${s3Key}`;
- console.info(`Archiving s3 mail ${s3Key} to ${newPath}...`);
+ console.info(logTag, `Archiving s3 mail ${s3Key} to ${newPath}...`);
await s3MoveObject(this.#s3, this.#bucket, mailPath, newPath);
- console.info(`Done archiving s3 mail ${s3Key}.`);
- console.info(`Done consuming s3 mail ${s3Key}.`);
+ console.info(logTag, `Done consuming s3 mail ${s3Key}.`);
}
async recycleLiveMails(consumer: AwsS3MailConsumer) {
console.info("Begin to recycle live mails...");
const mails = await this.listLiveMails();
console.info(`Found ${mails.length} live mails`);
+ let counter = 1;
for (const s3Key of mails) {
- await this.consumeS3Mail(s3Key, consumer);
+ await this.consumeS3Mail(
+ `[${counter++}/${mails.length}]`,
+ s3Key,
+ consumer,
+ );
}
}
}
diff --git a/deno/mail-relay/aws/mail.ts b/deno/mail-relay/aws/mail.ts
index 7ac2332..26f3ea0 100644
--- a/deno/mail-relay/aws/mail.ts
+++ b/deno/mail-relay/aws/mail.ts
@@ -8,17 +8,17 @@ 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(
+ context.logTag,
+ `Rewrite address-line string in mail: ${address} => ${awsMessageId}.`,
+ );
context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId);
}
}
- console.info("Done rewrite message ids.");
}
}
@@ -36,24 +36,24 @@ export class AwsMailMessageIdSaveHook implements MailDeliverHook {
}
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;
if (messageId == null) {
- console.info("Original mail does not have message id. Skip saving.");
+ console.warn(
+ context.logTag,
+ "Original mail doesn't have message id, skip saving message id map.",
+ );
return;
}
if (context.result.awsMessageId != null) {
- console.info(`Saving ${messageId} => ${context.result.awsMessageId}.`);
+ console.info(
+ context.logTag,
+ `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 55e1e9b..4fe2f41 100644
--- a/deno/mail-relay/dovecot.ts
+++ b/deno/mail-relay/dovecot.ts
@@ -6,7 +6,7 @@ ldaExitCodeMessageMap.set(67, "recipient user not known");
ldaExitCodeMessageMap.set(75, "temporary error");
type CommandResult = {
- kind: "exit-success" | "exit-failure";
+ kind: "exit";
status: Deno.CommandStatus;
logMessage: string;
} | { kind: "throw"; cause: unknown; logMessage: string };
@@ -14,14 +14,17 @@ type CommandResult = {
async function runCommand(
bin: string,
options: {
+ logTag: string;
args: string[];
stdin?: Uint8Array;
+ suppressResultLog?: boolean;
errorCodeMessageMap?: Map<number, string>;
},
): Promise<CommandResult> {
- const { args, stdin, errorCodeMessageMap } = options;
+ const { logTag, args, stdin, suppressResultLog, errorCodeMessageMap } =
+ options;
- console.info(`Run external command ${bin} ${args.join(" ")}`);
+ console.info(logTag, `Run external command ${bin} ${args.join(" ")}`);
try {
// Create and spawn process.
@@ -33,7 +36,6 @@ async function runCommand(
// Write stdin if any.
if (stdin != null) {
- console.info("Write stdin...");
const writer = process.stdin.getWriter();
await writer.write(stdin);
writer.close();
@@ -43,23 +45,23 @@ async function runCommand(
const status = await process.status;
// Build log message string.
- let message = `Command exited with code ${status.code}`;
+ 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 += ".";
- console.log(message);
+ if (suppressResultLog !== true) console.log(logTag, message);
// Return result.
return {
- kind: status.success ? "exit-success" : "exit-failure",
+ kind: "exit",
status,
logMessage: message,
};
} catch (cause) {
- const message = "Running command threw an error:";
- console.log(message, cause);
+ const message = `A JS error was thrown when invoking external command:`;
+ if (suppressResultLog !== true) console.log(logTag, message);
return { kind: "throw", cause, logMessage: message + " " + cause };
}
}
@@ -73,7 +75,7 @@ export class DovecotMailDeliverer extends MailDeliverer {
ldaPath: string,
doveadmPath: string,
) {
- super();
+ super(false);
this.#ldaPath = ldaPath;
this.#doveadmPath = doveadmPath;
}
@@ -87,36 +89,35 @@ export class DovecotMailDeliverer extends MailDeliverer {
const recipients = [...context.recipients];
if (recipients.length === 0) {
- context.result.smtpMessage =
- "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) {
const result = await runCommand(
this.#ldaPath,
{
+ logTag: context.logTag,
args: ["-d", recipient],
stdin: utf8Bytes,
+ suppressResultLog: true,
+ errorCodeMessageMap: ldaExitCodeMessageMap,
},
);
- if (result.kind === "exit-success") {
+ if (result.kind === "exit" && result.status.success) {
context.result.recipients.set(recipient, {
- kind: "done",
+ kind: "success",
message: result.logMessage,
});
} else {
context.result.recipients.set(recipient, {
- kind: "fail",
+ kind: "failure",
message: result.logMessage,
});
}
}
-
- console.info("Done handling all recipients.");
}
#queryArgs(mailbox: string, messageId: string) {
@@ -124,31 +125,38 @@ export class DovecotMailDeliverer extends MailDeliverer {
}
async #deleteMail(
+ logTag: string,
user: string,
mailbox: string,
messageId: string,
): Promise<void> {
- console.info(
- `Find and delete mails (user: ${user}, message-id: ${messageId}, mailbox: ${mailbox}).`,
- );
await runCommand(this.#doveadmPath, {
+ logTag,
args: ["expunge", "-u", user, ...this.#queryArgs(mailbox, messageId)],
});
}
- async #saveMail(user: string, mailbox: string, mail: Uint8Array) {
- console.info(`Save a mail (user: ${user}, mailbox: ${mailbox}).`);
+ async #saveMail(
+ logTag: string,
+ user: string,
+ mailbox: string,
+ mail: Uint8Array,
+ ) {
await runCommand(this.#doveadmPath, {
+ logTag,
args: ["save", "-u", user, "-m", mailbox],
stdin: mail,
});
}
- async #markAsRead(user: string, mailbox: string, messageId: string) {
- console.info(
- `Mark mails as \\Seen(user: ${user}, message-id: ${messageId}, mailbox: ${mailbox}, user: ${user}).`,
- );
+ async #markAsRead(
+ logTag: string,
+ user: string,
+ mailbox: string,
+ messageId: string,
+ ) {
await runCommand(this.#doveadmPath, {
+ logTag,
args: [
"flags",
"add",
@@ -160,50 +168,45 @@ export class DovecotMailDeliverer extends MailDeliverer {
});
}
- async saveNewSent(mail: Mail, messageIdToDelete: string) {
- console.info("Save sent mails and delete ones with old message id.");
+ async saveNewSent(logTag: string, mail: Mail, messageIdToDelete: string) {
+ console.info(logTag, "Save sent mail and delete ones with old message id.");
// Try to get from and recipients from headers.
- const headers = mail.startSimpleParse().sections().headers();
- const from = headers.from(),
- recipients = headers.recipients(),
- messageId = headers.messageId();
+ const { messageId, from, recipients } = mail.parsed;
if (from == null) {
- console.warn("Failed to determine from from headers, skip saving.");
+ console.warn(
+ logTag,
+ "Failed to get sender (from) in headers, skip saving.",
+ );
return;
}
- console.info("Parsed from: ", from);
-
- if (recipients.has(from)) {
+ if (recipients.includes(from)) {
// So the mail should lie in the Inbox.
console.info(
- "The mail has the sender itself as one of recipients, skip saving.",
+ logTag,
+ "One recipient of the mail is the sender itself, skip saving.",
);
return;
}
- await this.#saveMail(from, "Sent", mail.toUtf8Bytes());
+ await this.#saveMail(logTag, from, "Sent", mail.toUtf8Bytes());
if (messageId != null) {
- console.info("Mark sent mail as read.");
- await this.#markAsRead(from, "Sent", messageId);
+ await this.#markAsRead(logTag, from, "Sent", messageId);
} else {
console.warn(
- "New message id of the mail is not found, skip marking as read.",
+ "Message id of the mail is not found, skip marking as read.",
);
}
- console.info("Schedule deletion of old mails at 15,30,60 seconds later.");
+ console.info(
+ logTag,
+ "Schedule deletion of old mails at 15,30,60 seconds later.",
+ );
[15, 30, 60].forEach((seconds) =>
setTimeout(() => {
- console.info(
- `Try to delete mails in Sent. (message-id: ${messageIdToDelete}, ` +
- `attempt delay: ${seconds}s) ` +
- "Note that the mail may have already been deleted," +
- " in which case failures of deletion can be just ignored.",
- );
- void this.#deleteMail(from, "Sent", messageIdToDelete);
+ void this.#deleteMail(logTag, from, "Sent", messageIdToDelete);
}, 1000 * seconds)
);
}
diff --git a/deno/mail-relay/dumb-smtp-server.ts b/deno/mail-relay/dumb-smtp-server.ts
index 94502c4..70d5ec0 100644
--- a/deno/mail-relay/dumb-smtp-server.ts
+++ b/deno/mail-relay/dumb-smtp-server.ts
@@ -17,27 +17,25 @@ function createResponses(host: string, port: number | string) {
} as const;
}
-const LOG_TAG = "[dumb-smtp]";
-
export class DumbSmtpServer {
#deliverer;
- #responses: ReturnType<typeof createResponses> = createResponses(
- "invalid",
- "invalid",
- );
constructor(deliverer: MailDeliverer) {
this.#deliverer = deliverer;
}
- async #handleConnection(conn: Deno.Conn) {
+ async #handleConnection(
+ logTag: string,
+ conn: Deno.Conn,
+ responses: ReturnType<typeof createResponses>,
+ ) {
using disposeStack = new DisposableStack();
disposeStack.defer(() => {
- console.info(LOG_TAG, "Close session's tcp connection.");
+ console.info(logTag, "Close tcp connection.");
conn.close();
});
- console.info(LOG_TAG, "New session's tcp connection established.");
+ console.info(logTag, "New tcp connection established.");
const writer = conn.writable.getWriter();
disposeStack.defer(() => writer.releaseLock());
@@ -47,14 +45,14 @@ 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(logTag, "Send line:", s);
await writer.write(encoder.encode(s + CRLF));
};
let buffer: string = "";
let rawMail: string | null = null;
- await send(this.#responses["READY"]);
+ await send(responses["READY"]);
while (true) {
const { value, done } = await reader.read();
@@ -70,39 +68,37 @@ export class DumbSmtpServer {
buffer = buffer.slice(eolPos + CRLF.length);
if (rawMail == null) {
- console.info(LOG_TAG, "Received line: " + line);
+ console.info(logTag, "Received line:", line);
const upperLine = line.toUpperCase();
if (upperLine.startsWith("EHLO") || upperLine.startsWith("HELO")) {
- await send(this.#responses["EHLO"]);
+ await send(responses["EHLO"]);
} else if (upperLine.startsWith("MAIL FROM:")) {
- await send(this.#responses["MAIL"]);
+ await send(responses["MAIL"]);
} else if (upperLine.startsWith("RCPT TO:")) {
- await send(this.#responses["RCPT"]);
+ await send(responses["RCPT"]);
} else if (upperLine === "DATA") {
- await send(this.#responses["DATA"]);
- console.info(LOG_TAG, "Begin to receive mail data...");
+ await send(responses["DATA"]);
+ console.info(logTag, "Begin to receive mail data...");
rawMail = "";
} else if (upperLine === "QUIT") {
- await send(this.#responses["QUIT"]);
+ await send(responses["QUIT"]);
return;
} else {
- console.warn(LOG_TAG, "Unrecognized command from client: " + line);
- await send(this.#responses["INVALID"]);
+ await send(responses["INVALID"]);
return;
}
} else {
if (line === ".") {
try {
- console.info(LOG_TAG, "Mail data Received, begin to relay...");
+ console.info(logTag, "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);
+ console.error(logTag, "Relay failed.", err);
await send("554 5.3.0 Error: check server log");
}
- await send(this.#responses["ACTIVE_CLOSE"]);
+ await send(responses["ACTIVE_CLOSE"]);
} else {
const dataLine = line.startsWith("..") ? line.slice(1) : line;
rawMail += dataLine + CRLF;
@@ -114,17 +110,19 @@ export class DumbSmtpServer {
async serve(options: { hostname: string; port: number }) {
const listener = Deno.listen(options);
- this.#responses = createResponses(options.hostname, options.port);
+ const responses = createResponses(options.hostname, options.port);
console.info(
- LOG_TAG,
- `Dumb SMTP server starts to listen on ${this.#responses.serverName}.`,
+ `Dumb SMTP server starts to listen on ${responses.serverName}.`,
);
+ let counter = 1;
+
for await (const conn of listener) {
+ const logTag = `[outbound ${counter++}]`;
try {
- await this.#handleConnection(conn);
+ await this.#handleConnection(logTag, conn, responses);
} catch (cause) {
- console.error(LOG_TAG, "Tcp connection throws an error.", cause);
+ console.error(logTag, "A JS error was thrown by handler:", cause);
}
}
}
diff --git a/deno/mail-relay/mail-parsing.ts b/deno/mail-relay/mail-parsing.ts
new file mode 100644
index 0000000..8e9697d
--- /dev/null
+++ b/deno/mail-relay/mail-parsing.ts
@@ -0,0 +1,144 @@
+import emailAddresses from "email-addresses";
+
+class MailParsingError extends Error {}
+
+function parseHeaderSection(section: string) {
+ 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 MailParsingError(
+ `Expect ':' in the header field line: ${field}`,
+ );
+ }
+ headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]);
+ field = null;
+ };
+
+ for (const line of section.trimEnd().split(/\r?\n|\r/)) {
+ if (line.match(/^\s/)) {
+ if (field == null) {
+ throw new MailParsingError("Header section starts with a space.");
+ }
+ field += line;
+ } else {
+ handleField();
+ field = line;
+ }
+ lineNumber += 1;
+ }
+
+ handleField();
+
+ return headers;
+}
+
+function findFirst(fields: readonly [string, string][], key: string) {
+ for (const [k, v] of fields) {
+ if (key.toLowerCase() === k.toLowerCase()) return v;
+ }
+ return undefined;
+}
+
+function findMessageId(fields: readonly [string, string][]) {
+ const messageIdField = findFirst(fields, "message-id");
+ if (messageIdField == null) return undefined;
+
+ const match = messageIdField.match(/\<(.*?)\>/);
+ if (match != null) {
+ return match[1];
+ } else {
+ console.warn(`Invalid syntax in header 'message-id': ${messageIdField}`);
+ return undefined;
+ }
+}
+
+function findDate(fields: readonly [string, string][]) {
+ const dateField = findFirst(fields, "date");
+ if (dateField == null) return undefined;
+
+ const date = new Date(dateField);
+ if (isNaN(date.getTime())) {
+ console.warn(`Invalid date string in header 'date': ${dateField}`);
+ return undefined;
+ }
+ return date;
+}
+
+function findFrom(fields: readonly [string, string][]) {
+ const fromField = findFirst(fields, "from");
+ if (fromField == null) return undefined;
+
+ const addr = emailAddresses.parseOneAddress(fromField);
+ return addr?.type === "mailbox" ? addr.address : undefined;
+}
+
+function findRecipients(fields: readonly [string, string][]) {
+ const headers = ["to", "cc", "bcc", "x-original-to"];
+ const recipients = new Set<string>();
+ for (const [key, value] of fields) {
+ if (headers.includes(key.toLowerCase())) {
+ emailAddresses
+ .parseAddressList(value)
+ ?.flatMap((a) => (a.type === "mailbox" ? a : a.addresses))
+ ?.forEach(({ address }) => recipients.add(address));
+ }
+ }
+ return recipients;
+}
+
+function parseSections(raw: string) {
+ const twoEolMatch = raw.match(/(\r?\n)(\r?\n)/);
+ if (twoEolMatch == null) {
+ throw new MailParsingError(
+ "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: raw.slice(0, twoEolMatch.index!),
+ body: raw.slice(twoEolMatch.index! + eol.length + sep.length),
+ eol,
+ sep,
+ };
+}
+
+export type ParsedMail = Readonly<{
+ header: string;
+ body: string;
+ sep: string;
+ eol: string;
+ headers: readonly [string, string][];
+ messageId: string | undefined;
+ date: Date | undefined;
+ from: string | undefined;
+ recipients: readonly string[];
+}>;
+
+export function simpleParseMail(raw: string): ParsedMail {
+ const sections = Object.freeze(parseSections(raw));
+ const headers = Object.freeze(parseHeaderSection(sections.header));
+ const messageId = findMessageId(headers);
+ const date = findDate(headers);
+ const from = findFrom(headers);
+ const recipients = Object.freeze([...findRecipients(headers)]);
+ return Object.freeze({
+ ...sections,
+ headers,
+ messageId,
+ date,
+ from,
+ recipients,
+ });
+}
diff --git a/deno/mail-relay/mail.test.ts b/deno/mail-relay/mail.test.ts
index cd0c38d..a8204be 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,29 @@ 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,
).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,11 +105,14 @@ 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"];
}
- const mockDeliverer = new MockMailDeliverer();
+ const mockDeliverer = new MockMailDeliverer(false);
it("deliver success", async () => {
await mockDeliverer.deliverRaw(mockMailStr);
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);