aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuqian Yang <crupest@crupest.life>2025-06-17 15:14:48 +0800
committerYuqian Yang <crupest@crupest.life>2025-06-17 15:14:48 +0800
commitf42e43e12e7689fef47fb7af3eae625eedbb9d6c (patch)
tree081bc88e2a330d6d50f5718161918fb880292b72
parente5509b819a2798077232fb014926e7abc7bf9edc (diff)
downloadcrupest-f42e43e12e7689fef47fb7af3eae625eedbb9d6c.tar.gz
crupest-f42e43e12e7689fef47fb7af3eae625eedbb9d6c.tar.bz2
crupest-f42e43e12e7689fef47fb7af3eae625eedbb9d6c.zip
mail: skip saving if cc myself, mark sent as read.
-rw-r--r--deno/base/deno.json1
-rw-r--r--deno/base/log.ts60
-rw-r--r--deno/mail-relay/app.ts9
-rw-r--r--deno/mail-relay/aws/app.ts12
-rw-r--r--deno/mail-relay/dovecot.ts267
-rw-r--r--store/config/nvim/lazy-lock.json4
6 files changed, 153 insertions, 200 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 8cb33e6..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,7 +11,6 @@ import { DovecotMailDeliverer } from "./dovecot.ts";
import { DumbSmtpServer } from "./dumb-smtp-server.ts";
export function createInbound(
- logFileProvider: LogFileProvider,
{
fallback,
mailDomain,
@@ -28,11 +25,7 @@ export function createInbound(
doveadmPath: string;
},
) {
- const deliverer = new DovecotMailDeliverer(
- logFileProvider,
- ldaPath,
- doveadmPath,
- );
+ 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 86f7c6b..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";
@@ -111,7 +110,7 @@ function createOutbound(
new AwsMailMessageIdSaveHook(
async (original, aws, context) => {
await db.addMessageIdMap({ message_id: original, aws_message_id: aws });
- void local?.saveNewSent(original, context.mail);
+ void local?.saveNewSent(context.mail, original);
},
),
);
@@ -164,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() {
@@ -186,9 +182,9 @@ 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"),
diff --git a/deno/mail-relay/dovecot.ts b/deno/mail-relay/dovecot.ts
index 6d291ee..e8469e6 100644
--- a/deno/mail-relay/dovecot.ts
+++ b/deno/mail-relay/dovecot.ts
@@ -1,22 +1,81 @@
import { basename } from "@std/path";
-import { LogFileProvider } from "@crupest/base/log";
-
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;
+ errorCodeMessageMap?: Map<number, string>;
+ },
+): Promise<CommandResult> {
+ const { args, stdin, 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) {
+ console.info("Write stdin...");
+ const writer = process.stdin.getWriter();
+ await writer.write(stdin);
+ writer.close();
+ }
+
+ // Wait for process to exit.
+ const status = await process.status;
+
+ // Build log message string.
+ let message = `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);
+
+ // Return result.
+ return {
+ kind: status.success ? "exit-success" : "exit-failure",
+ status,
+ logMessage: message,
+ };
+ } catch (cause) {
+ const message = "Running command threw an error:";
+ 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,
doveadmPath: string,
) {
super();
- this.#logFileProvider = logFileProvider;
this.#ldaPath = ldaPath;
this.#doveadmPath = doveadmPath;
}
@@ -25,9 +84,7 @@ export class DovecotMailDeliverer extends MailDeliverer {
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];
@@ -40,62 +97,23 @@ export class DovecotMailDeliverer extends MailDeliverer {
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,
+ },
+ );
- 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) {
+ if (result.kind === "exit-success") {
+ context.result.recipients.set(recipient, {
+ kind: "done",
+ message: result.logMessage,
+ });
+ } else {
context.result.recipients.set(recipient, {
kind: "fail",
- message: "An error is thrown when running lda: " + cause,
- cause,
+ message: result.logMessage,
});
}
}
@@ -103,80 +121,87 @@ export class DovecotMailDeliverer extends MailDeliverer {
console.info("Done handling all recipients.");
}
+ #queryArgs(mailbox: string, messageId: string) {
+ return ["mailbox", mailbox, "header", "Message-ID", `<${messageId}>`];
+ }
+
async #deleteMail(
user: string,
mailbox: string,
messageId: string,
): Promise<void> {
- try {
- const args = [
- "expunge",
+ console.info(
+ `Find and delete mails (user: ${user}, message-id: ${messageId}, mailbox: ${mailbox}).`,
+ );
+ await runCommand(this.#doveadmPath, {
+ 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}).`);
+ await runCommand(this.#doveadmPath, {
+ 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}).`,
+ );
+ await runCommand(this.#doveadmPath, {
+ args: [
+ "flags",
+ "add",
"-u",
user,
- "mailbox",
- mailbox,
- "header",
- "Message-ID",
- `<${messageId}>`,
- ];
- console.info(
- `Run external command ${this.#doveadmPath} ${args.join(" ")} ...`,
- );
- const command = new Deno.Command(this.#doveadmPath, { args });
- const status = await command.spawn().status;
- if (status.success) {
- console.info("Expunged successfully.");
- } else {
- console.warn("Expunging failed with exit code %d.", status.code);
- }
- } catch (cause) {
- console.warn("Expunging failed with an error thrown: ", cause);
- }
+ "\\Seen",
+ ...this.#queryArgs(mailbox, messageId),
+ ],
+ });
}
- async #saveMail(user: string, mailbox: string, mail: Uint8Array) {
- try {
- const args = ["save", "-u", user, "-m", mailbox];
+ 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 headers = mail.startSimpleParse().sections().headers();
+ const from = headers.from(),
+ recipients = headers.recipients(),
+ messageId = headers.messageId();
+
+ if (from == null) {
+ console.warn("Failed to determine from from headers, skip saving.");
+ return;
+ }
+
+ console.info("Parsed from: ", from);
+
+ if (recipients.has(from)) {
+ // So the mail should lie in the Inbox.
console.info(
- `Run external command ${this.#doveadmPath} ${args.join(" ")} ...`,
+ "The mail has the sender itself as one of recipients, skip saving.",
);
- const command = new Deno.Command(this.#doveadmPath, {
- args,
- stdin: "piped",
- });
- const process = command.spawn();
- const stdinWriter = process.stdin.getWriter();
- await stdinWriter.write(mail);
- await stdinWriter.close();
- const status = await process.status;
-
- if (status.success) {
- console.info("Saved successfully.");
- } else {
- console.warn("Saving failed with exit code %d.", status.code);
- }
- } catch (cause) {
- console.warn("Saving failed with an error thrown: ", cause);
+ return;
}
- }
- async saveNewSent(originalMessageId: string, mail: Mail) {
- console.info(
- "Try to save mail with new id and delete mail with old id in Sent box.",
- );
- const from = mail.startSimpleParse().sections().headers()
- .from();
- if (from != null) {
- console.info("Parsed sender (from): ", from);
- await this.#saveMail(from, "Sent", mail.toUtf8Bytes());
- setTimeout(() => {
- console.info(
- "Try to delete mail in Sent box that has old message id.",
- );
- this.#deleteMail(from, "Sent", originalMessageId);
- }, 1000 * 15);
+ await this.#saveMail(from, "Sent", mail.toUtf8Bytes());
+ if (messageId != null) {
+ console.info("Mark sent mail as read.");
+ await this.#markAsRead(from, "Sent", messageId);
} else {
- console.warn("Failed to determine from.");
+ console.warn(
+ "New message id of the mail is not found, skip marking as read.",
+ );
}
+
+ console.info("Schedule deletion of old mails at 15 seconds later.");
+ setTimeout(() => {
+ console.info(
+ "Try to delete mails in Sent box that has old message id.",
+ );
+ void this.#deleteMail(from, "Sent", messageIdToDelete);
+ }, 1000 * 15);
}
}
diff --git a/store/config/nvim/lazy-lock.json b/store/config/nvim/lazy-lock.json
index 4f6c2b5..cdb120a 100644
--- a/store/config/nvim/lazy-lock.json
+++ b/store/config/nvim/lazy-lock.json
@@ -3,7 +3,7 @@
"cmp-buffer": { "branch": "main", "commit": "b74fab3656eea9de20a9b8116afa3cfc4ec09657" },
"cmp-nvim-lsp": { "branch": "main", "commit": "a8912b88ce488f411177fc8aed358b04dc246d7b" },
"cmp-path": { "branch": "main", "commit": "c6635aae33a50d6010bf1aa756ac2398a2d54c32" },
- "gitsigns.nvim": { "branch": "main", "commit": "731b581428ec6c1ccb451b95190ebbc6d7006db7" },
+ "gitsigns.nvim": { "branch": "main", "commit": "88205953bd748322b49b26e1dfb0389932520dc9" },
"lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" },
"lualine.nvim": { "branch": "master", "commit": "a94fc68960665e54408fe37dcf573193c4ce82c9" },
"neo-tree.nvim": { "branch": "v3.x", "commit": "f481de16a0eb59c985abac8985e3f2e2f75b4875" },
@@ -11,7 +11,7 @@
"nvim-autopairs": { "branch": "master", "commit": "4d74e75913832866aa7de35e4202463ddf6efd1b" },
"nvim-cmp": { "branch": "main", "commit": "b5311ab3ed9c846b585c0c15b7559be131ec4be9" },
"nvim-lint": { "branch": "master", "commit": "2b0039b8be9583704591a13129c600891ac2c596" },
- "nvim-lspconfig": { "branch": "master", "commit": "7ad4a11cc5742774877c529fcfb2702f7caf75e4" },
+ "nvim-lspconfig": { "branch": "master", "commit": "463b16bd6a347a129367a7fd00ebcdd9442d9a96" },
"nvim-treesitter": { "branch": "master", "commit": "42fc28ba918343ebfd5565147a42a26580579482" },
"nvim-web-devicons": { "branch": "master", "commit": "1fb58cca9aebbc4fd32b086cb413548ce132c127" },
"plenary.nvim": { "branch": "master", "commit": "857c5ac632080dba10aae49dba902ce3abf91b35" },