diff options
author | Yuqian Yang <crupest@crupest.life> | 2025-06-17 15:14:48 +0800 |
---|---|---|
committer | Yuqian Yang <crupest@crupest.life> | 2025-06-17 21:37:43 +0800 |
commit | e3acdfd274086a183402b2f0c75e54c620bf954a (patch) | |
tree | 6e2ed8a38dcf2d8593f962d3c98f954c8d2c19d3 | |
parent | dabfabbe0779b2f530db7351afa99cc38ce40d8f (diff) | |
download | crupest-e3acdfd274086a183402b2f0c75e54c620bf954a.tar.gz crupest-e3acdfd274086a183402b2f0c75e54c620bf954a.tar.bz2 crupest-e3acdfd274086a183402b2f0c75e54c620bf954a.zip |
mail: skip saving if cc myself, mark sent as read.
-rw-r--r-- | deno/base/deno.json | 1 | ||||
-rw-r--r-- | deno/base/log.ts | 60 | ||||
-rw-r--r-- | deno/mail-relay/app.ts | 9 | ||||
-rw-r--r-- | deno/mail-relay/aws/app.ts | 12 | ||||
-rw-r--r-- | deno/mail-relay/dovecot.ts | 267 | ||||
-rw-r--r-- | store/config/nvim/lazy-lock.json | 4 |
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" }, |