diff options
Diffstat (limited to 'deno')
| -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 | 
5 files changed, 151 insertions, 198 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);    }  } | 
