diff options
Diffstat (limited to 'deno/mail-relay/dovecot.ts')
-rw-r--r-- | deno/mail-relay/dovecot.ts | 228 |
1 files changed, 159 insertions, 69 deletions
diff --git a/deno/mail-relay/dovecot.ts b/deno/mail-relay/dovecot.ts index bace225..8e6e754 100644 --- a/deno/mail-relay/dovecot.ts +++ b/deno/mail-relay/dovecot.ts @@ -1,99 +1,189 @@ -import { basename } from "@std/path"; +import { Mail, MailDeliverContext, MailDeliverer } from "./mail.ts"; + +// https://doc.dovecot.org/main/core/man/dovecot-lda.1.html +const ldaExitCodeMessageMap = new Map<number, string>(); +ldaExitCodeMessageMap.set(67, "recipient user not known"); +ldaExitCodeMessageMap.set(75, "temporary error"); + +type CommandResult = { + kind: "exit-success" | "exit-failure"; + status: Deno.CommandStatus; + logMessage: string; +} | { kind: "throw"; cause: unknown; logMessage: string }; + +async function runCommand( + bin: string, + options: { + args: string[]; + stdin?: Uint8Array; + suppressResultLog?: boolean; + errorCodeMessageMap?: Map<number, string>; + }, +): Promise<CommandResult> { + const { args, stdin, suppressResultLog, errorCodeMessageMap } = options; + + console.info(`Run external command ${bin} ${args.join(" ")}`); + + try { + // Create and spawn process. + const command = new Deno.Command(bin, { + args, + stdin: stdin == null ? "null" : "piped", + }); + const process = command.spawn(); + + // Write stdin if any. + if (stdin != null) { + const writer = process.stdin.getWriter(); + await writer.write(stdin); + writer.close(); + } -import { LogFileProvider } from "@crupest/base/log"; + // Wait for process to exit. + const status = await process.status; -import { Mail, MailDeliverContext, MailDeliverer } from "./mail.ts"; + // Build log message string. + let message = `External command exited with code ${status.code}`; + if (status.signal != null) message += ` (signal: ${status.signal})`; + if (errorCodeMessageMap != null && errorCodeMessageMap.has(status.code)) { + message += `, ${errorCodeMessageMap.get(status.code)}`; + } + message += "."; + suppressResultLog || console.log(message); + + // Return result. + return { + kind: status.success ? "exit-success" : "exit-failure", + status, + logMessage: message, + }; + } catch (cause) { + const message = `A JS error was thrown when invoking external command:`; + suppressResultLog || console.log(message, cause); + return { kind: "throw", cause, logMessage: message + " " + cause }; + } +} export class DovecotMailDeliverer extends MailDeliverer { readonly name = "dovecot"; - readonly #logFileProvider; readonly #ldaPath; + readonly #doveadmPath; - constructor(logFileProvider: LogFileProvider, ldaPath: string) { + constructor( + ldaPath: string, + doveadmPath: string, + ) { super(); - this.#logFileProvider = logFileProvider; this.#ldaPath = ldaPath; + this.#doveadmPath = doveadmPath; } protected override async doDeliver( mail: Mail, context: MailDeliverContext, ): Promise<void> { - const ldaPath = this.#ldaPath; - const ldaBinName = basename(ldaPath); - const utf8Stream = mail.toUtf8Bytes(); + const utf8Bytes = mail.toUtf8Bytes(); const recipients = [...context.recipients]; if (recipients.length === 0) { - context.result.message = - "Failed to deliver to dovecot, no recipients are specified."; - return; + throw new Error( + "Failed to deliver to dovecot, no recipients are specified.", + ); } - console.info(`Deliver to dovecot users: ${recipients.join(", ")}.`); - for (const recipient of recipients) { - try { - const commandArgs = ["-d", recipient]; - console.info(`Run ${ldaBinName} ${commandArgs.join(" ")}...`); - - const ldaCommand = new Deno.Command(ldaPath, { - args: commandArgs, - stdin: "piped", - stdout: "piped", - stderr: "piped", + const result = await runCommand( + this.#ldaPath, + { + args: ["-d", recipient], + stdin: utf8Bytes, + suppressResultLog: true, + errorCodeMessageMap: ldaExitCodeMessageMap, + }, + ); + + if (result.kind === "exit-success") { + context.result.recipients.set(recipient, { + kind: "success", + message: result.logMessage, }); - - const ldaProcess = ldaCommand.spawn(); - using logFiles = await this.#logFileProvider - .createExternalLogStreamsForProgram( - ldaBinName, - ); - ldaProcess.stdout.pipeTo(logFiles.stdout); - ldaProcess.stderr.pipeTo(logFiles.stderr); - - const stdinWriter = ldaProcess.stdin.getWriter(); - await stdinWriter.write(utf8Stream); - await stdinWriter.close(); - - const status = await ldaProcess.status; - - if (status.success) { - context.result.recipients.set(recipient, { - kind: "done", - message: `${ldaBinName} exited with success.`, - }); - } else { - let message = `${ldaBinName} exited with error code ${status.code}`; - - if (status.signal != null) { - message += ` (signal ${status.signal})`; - } - - // https://doc.dovecot.org/main/core/man/dovecot-lda.1.html - switch (status.code) { - case 67: - message += ", recipient user not known"; - break; - case 75: - message += ", temporary error"; - break; - } - - message += "."; - - context.result.recipients.set(recipient, { kind: "fail", message }); - } - } catch (cause) { + } else { context.result.recipients.set(recipient, { - kind: "fail", - message: "An error is thrown when running lda: " + cause, - cause, + kind: "failure", + message: result.logMessage, }); } } + } + + #queryArgs(mailbox: string, messageId: string) { + return ["mailbox", mailbox, "header", "Message-ID", `<${messageId}>`]; + } + + async #deleteMail( + user: string, + mailbox: string, + messageId: string, + ): Promise<void> { + await runCommand(this.#doveadmPath, { + args: ["expunge", "-u", user, ...this.#queryArgs(mailbox, messageId)], + }); + } + + async #saveMail(user: string, mailbox: string, mail: Uint8Array) { + await runCommand(this.#doveadmPath, { + args: ["save", "-u", user, "-m", mailbox], + stdin: mail, + }); + } + + async #markAsRead(user: string, mailbox: string, messageId: string) { + await runCommand(this.#doveadmPath, { + args: [ + "flags", + "add", + "-u", + user, + "\\Seen", + ...this.#queryArgs(mailbox, messageId), + ], + }); + } + + async saveNewSent(mail: Mail, messageIdToDelete: string) { + console.info("Save sent mails and delete ones with old message id."); + + // Try to get from and recipients from headers. + const { messageId, from, recipients } = mail.parsed; + + if (from == null) { + console.warn("Failed to get sender (from) in headers, skip saving."); + return; + } + + if (recipients.has(from)) { + // So the mail should lie in the Inbox. + console.info( + "One recipient of the mail is the sender itself, skip saving.", + ); + return; + } + + await this.#saveMail(from, "Sent", mail.toUtf8Bytes()); + if (messageId != null) { + await this.#markAsRead(from, "Sent", messageId); + } else { + console.warn( + "Message id of the mail is not found, skip marking as read.", + ); + } - console.info("Done handling all recipients."); + console.info("Schedule deletion of old mails at 15,30,60 seconds later."); + [15, 30, 60].forEach((seconds) => + setTimeout(() => { + void this.#deleteMail(from, "Sent", messageIdToDelete); + }, 1000 * seconds) + ); } } |