aboutsummaryrefslogtreecommitdiff
path: root/deno/mail/dovecot.ts
diff options
context:
space:
mode:
Diffstat (limited to 'deno/mail/dovecot.ts')
-rw-r--r--deno/mail/dovecot.ts213
1 files changed, 213 insertions, 0 deletions
diff --git a/deno/mail/dovecot.ts b/deno/mail/dovecot.ts
new file mode 100644
index 0000000..4fe2f41
--- /dev/null
+++ b/deno/mail/dovecot.ts
@@ -0,0 +1,213 @@
+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";
+ status: Deno.CommandStatus;
+ logMessage: string;
+} | { kind: "throw"; cause: unknown; logMessage: string };
+
+async function runCommand(
+ bin: string,
+ options: {
+ logTag: string;
+ args: string[];
+ stdin?: Uint8Array;
+ suppressResultLog?: boolean;
+ errorCodeMessageMap?: Map<number, string>;
+ },
+): Promise<CommandResult> {
+ const { logTag, args, stdin, suppressResultLog, errorCodeMessageMap } =
+ options;
+
+ console.info(logTag, `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();
+ }
+
+ // Wait for process to exit.
+ const status = await process.status;
+
+ // 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 += ".";
+ if (suppressResultLog !== true) console.log(logTag, message);
+
+ // Return result.
+ return {
+ kind: "exit",
+ status,
+ logMessage: message,
+ };
+ } catch (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 };
+ }
+}
+
+export class DovecotMailDeliverer extends MailDeliverer {
+ readonly name = "dovecot";
+ readonly #ldaPath;
+ readonly #doveadmPath;
+
+ constructor(
+ ldaPath: string,
+ doveadmPath: string,
+ ) {
+ super(false);
+ this.#ldaPath = ldaPath;
+ this.#doveadmPath = doveadmPath;
+ }
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ const utf8Bytes = mail.toUtf8Bytes();
+
+ const recipients = [...context.recipients];
+
+ if (recipients.length === 0) {
+ throw new Error(
+ "Failed to deliver to dovecot, no recipients are specified.",
+ );
+ }
+
+ 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" && result.status.success) {
+ context.result.recipients.set(recipient, {
+ kind: "success",
+ message: result.logMessage,
+ });
+ } else {
+ context.result.recipients.set(recipient, {
+ kind: "failure",
+ message: result.logMessage,
+ });
+ }
+ }
+ }
+
+ #queryArgs(mailbox: string, messageId: string) {
+ return ["mailbox", mailbox, "header", "Message-ID", `<${messageId}>`];
+ }
+
+ async #deleteMail(
+ logTag: string,
+ user: string,
+ mailbox: string,
+ messageId: string,
+ ): Promise<void> {
+ await runCommand(this.#doveadmPath, {
+ logTag,
+ args: ["expunge", "-u", user, ...this.#queryArgs(mailbox, messageId)],
+ });
+ }
+
+ 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(
+ logTag: string,
+ user: string,
+ mailbox: string,
+ messageId: string,
+ ) {
+ await runCommand(this.#doveadmPath, {
+ logTag,
+ args: [
+ "flags",
+ "add",
+ "-u",
+ user,
+ "\\Seen",
+ ...this.#queryArgs(mailbox, messageId),
+ ],
+ });
+ }
+
+ 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 { messageId, from, recipients } = mail.parsed;
+
+ if (from == null) {
+ console.warn(
+ logTag,
+ "Failed to get sender (from) in headers, skip saving.",
+ );
+ return;
+ }
+
+ if (recipients.includes(from)) {
+ // So the mail should lie in the Inbox.
+ console.info(
+ logTag,
+ "One recipient of the mail is the sender itself, skip saving.",
+ );
+ return;
+ }
+
+ await this.#saveMail(logTag, from, "Sent", mail.toUtf8Bytes());
+ if (messageId != null) {
+ await this.#markAsRead(logTag, from, "Sent", messageId);
+ } else {
+ console.warn(
+ "Message id of the mail is not found, skip marking as read.",
+ );
+ }
+
+ console.info(
+ logTag,
+ "Schedule deletion of old mails at 15,30,60 seconds later.",
+ );
+ [15, 30, 60].forEach((seconds) =>
+ setTimeout(() => {
+ void this.#deleteMail(logTag, from, "Sent", messageIdToDelete);
+ }, 1000 * seconds)
+ );
+ }
+}