diff options
Diffstat (limited to 'deno/mail-relay/aws')
| -rw-r--r-- | deno/mail-relay/aws/app.ts | 380 | ||||
| -rw-r--r-- | deno/mail-relay/aws/context.ts | 41 | ||||
| -rw-r--r-- | deno/mail-relay/aws/deliver.ts | 77 | ||||
| -rw-r--r-- | deno/mail-relay/aws/fetch.ts | 131 | ||||
| -rw-r--r-- | deno/mail-relay/aws/mail.ts | 53 | ||||
| -rw-r--r-- | deno/mail-relay/aws/retriever.ts | 100 | 
6 files changed, 470 insertions, 312 deletions
diff --git a/deno/mail-relay/aws/app.ts b/deno/mail-relay/aws/app.ts index 1fda64e..685d7a9 100644 --- a/deno/mail-relay/aws/app.ts +++ b/deno/mail-relay/aws/app.ts @@ -1,113 +1,266 @@ +import { join } from "@std/path";  import { parseArgs } from "@std/cli";  import { z } from "zod"; +import { Hono } from "hono";  import { zValidator } from "@hono/zod-validator"; +import { FetchHttpHandler } from "@smithy/fetch-http-handler"; -import log from "../log.ts"; -import config from "../config.ts"; -import { AppBase } from "../app.ts"; -import { AwsContext } from "./context.ts"; +import { Logger } from "@crupest/base/log"; +import { ConfigDefinition, ConfigProvider } from "@crupest/base/config"; +import { CronTask } from "@crupest/base/cron"; + +import { DbService } from "../db.ts"; +import { Mail } from "../mail.ts";  import { -  AwsMailDeliverer,    AwsMailMessageIdRewriteHook,    AwsMailMessageIdSaveHook, -} from "./deliver.ts"; -import { AwsMailRetriever } from "./retriever.ts"; - -export class AwsRelayApp extends AppBase { -  readonly #aws = new AwsContext(); -  readonly #retriever; -  protected readonly outboundDeliverer = new AwsMailDeliverer(this.#aws); - -  constructor() { -    super(); -    this.#retriever = new AwsMailRetriever(this.#aws, this.inboundDeliverer); - -    this.outboundDeliverer.preHooks.push( -      new AwsMailMessageIdRewriteHook(this.db), -    ); -    this.outboundDeliverer.postHooks.push( -      new AwsMailMessageIdSaveHook(this.db), -    ); - -    this.hono.post( -      `/${config.get("awsInboundPath")}`, -      async (ctx, next) => { -        const auth = ctx.req.header("Authorization"); -        if (auth !== config.get("awsInboundKey")) { -          return ctx.json({ "msg": "Bad auth!" }, 403); -        } -        await next(); -      }, -      zValidator( -        "json", -        z.object({ -          key: z.string(), -          recipients: z.optional(z.array(z.string())), -        }), -      ), -      async (ctx) => { -        const { key, recipients } = ctx.req.valid("json"); -        await this.#retriever.deliverS3Mail(key, recipients); -        return ctx.json({ "msg": "Done!" }); -      }, -    ); -  } +} from "./mail.ts"; +import { AwsMailDeliverer } from "./deliver.ts"; +import { AwsMailFetcher, AwsS3MailConsumer } from "./fetch.ts"; +import { createInbound, createHono, sendMail, createSmtp } from "../app.ts"; -  realServe() { -    this.createCron({ -      name: "live-mail-recycler", -      interval: 6 * 3600 * 1000, -      callback: () => { -        return this.#retriever.recycleLiveMails(); -      }, -      startNow: true, -    }); - -    return this.serve(); -  } +const PREFIX = "crupest-mail-server"; +const CONFIG_DEFINITIONS = { +  dataPath: { +    description: "Path to save app persistent data.", +    default: ".", +  }, +  mailDomain: { +    description: +      "The part after `@` of an address. Used to determine local recipients.", +  }, +  httpHost: { +    description: "Listening address for http server.", +    default: "0.0.0.0", +  }, +  httpPort: { description: "Listening port for http server.", default: "2345" }, +  smtpHost: { +    description: "Listening address for dumb smtp server.", +    default: "127.0.0.1", +  }, +  smtpPort: { +    description: "Listening port for dumb smtp server.", +    default: "2346", +  }, +  ldaPath: { +    description: "full path of lda executable", +    default: "/dovecot/libexec/dovecot/dovecot-lda", +  }, +  inboundFallback: { +    description: "comma separated addresses used as fallback recipients", +    default: "", +  }, +  awsInboundPath: { +    description: "(random set) path for aws sns", +  }, +  awsInboundKey: { +    description: "(random set) http header Authorization for aws sns", +  }, +  awsRegion: { +    description: "aws region", +  }, +  awsUser: { +    description: "aws access key id", +  }, +  awsPassword: { +    description: "aws secret access key", +    secret: true, +  }, +  awsMailBucket: { +    description: "aws s3 bucket saving raw mails", +    secret: true, +  }, +} as const satisfies ConfigDefinition; + +function createAwsOptions({ +  user, +  password, +  region, +}: { +  user: string; +  password: string; +  region: string; +}) { +  return { +    credentials: () => +      Promise.resolve({ +        accessKeyId: user, +        secretAccessKey: password, +      }), +    requestHandler: new FetchHttpHandler(), +    region, +  }; +} -  readonly cli = { -    "init": (_: unknown) => { -      log.info("Just init!"); -      return Promise.resolve(); +function createOutbound( +  logger: Logger, +  awsOptions: ReturnType<typeof createAwsOptions>, +  db: DbService, +) { +  const deliverer = new AwsMailDeliverer(logger, awsOptions); +  deliverer.preHooks.push( +    new AwsMailMessageIdRewriteHook(db.messageIdToAws.bind(db)), +  ); +  deliverer.postHooks.push( +    new AwsMailMessageIdSaveHook((original, aws) => +      db.addMessageIdMap({ message_id: original, aws_message_id: aws }).then(), +    ), +  ); +  return deliverer; +} + +function setupAwsHono( +  hono: Hono, +  options: { +    path: string; +    auth: string; +    callback: (s3Key: string, recipients?: string[]) => Promise<void>; +  }, +) { +  hono.post( +    `/${options.path}`, +    async (ctx, next) => { +      const auth = ctx.req.header("Authorization"); +      if (auth !== options.auth) { +        return ctx.json({ msg: "Bad auth!" }, 403); +      } +      await next();      }, -    "list-lives": async (_: unknown) => { -      const liveMails = await this.#retriever.listLiveMails(); -      log.info(`Total ${liveMails.length}:`); -      log.info(liveMails.join("\n")); +    zValidator( +      "json", +      z.object({ +        key: z.string(), +        recipients: z.optional(z.array(z.string())), +      }), +    ), +    async (ctx) => { +      const { key, recipients } = ctx.req.valid("json"); +      await options.callback(key, recipients); +      return ctx.json({ msg: "Done!" });      }, -    "recycle-lives": async (_: unknown) => { -      await this.#retriever.recycleLiveMails(); +  ); +} + +function createCron(fetcher: AwsMailFetcher, consumer: AwsS3MailConsumer) { +  return new CronTask({ +    name: "live-mail-recycler", +    interval: 6 * 3600 * 1000, +    callback: () => { +      return fetcher.recycleLiveMails(consumer);      }, -    "serve": async (_: unknown) => { -      await this.serve().http.finished; +    startNow: true, +  }); +} + +function createBaseServices() { +  const config = new ConfigProvider(PREFIX, CONFIG_DEFINITIONS); +  Deno.mkdirSync(config.get("dataPath"), { recursive: true }); +  const logger = new Logger(); +  logger.externalLogDir = join(config.get("dataPath"), "log"); +  return { config, logger }; +} + +function createAwsFetchOnlyServices() { +  const { config, logger } = createBaseServices(); +  const awsOptions = createAwsOptions({ +    user: config.get("awsUser"), +    password: config.get("awsPassword"), +    region: config.get("awsRegion"), +  }); +  const fetcher = new AwsMailFetcher( +    logger, +    awsOptions, +    config.get("awsMailBucket"), +  ); +  return { config, logger, awsOptions, fetcher }; +} + +function createAwsRecycleOnlyServices() { +  const { config, logger, awsOptions, fetcher } = createAwsFetchOnlyServices(); + +  const inbound = createInbound(logger, { +    fallback: config.getList("inboundFallback"), +    ldaPath: config.get("ldaPath"), +    aliasFile: join(config.get("dataPath"), "aliases.csv"), +    mailDomain: config.get("mailDomain"), +  }); + +  const recycler = (rawMail: string, _: unknown): Promise<void> => +    inbound.deliver({ mail: new Mail(rawMail) }).then(); + +  return { config, logger, awsOptions, fetcher, inbound, recycler }; +} +function createAwsServices() { +  const { config, logger, inbound, awsOptions, fetcher, recycler } = +    createAwsRecycleOnlyServices(); +  const dbService = new DbService(join(config.get("dataPath"), "db.sqlite")); +  const outbound = createOutbound(logger, awsOptions, dbService); + +  return { +    config, +    logger, +    inbound, +    dbService, +    awsOptions, +    fetcher, +    recycler, +    outbound, +  }; +} + +function createServerServices() { +  const services = createAwsServices(); +  const { logger, config, outbound, inbound, fetcher } = services; +  const smtp = createSmtp(logger, outbound); + +  const hono = createHono(logger, outbound, inbound); +  setupAwsHono(hono, { +    path: config.get("awsInboundPath"), +    auth: config.get("awsInboundKey"), +    callback: (s3Key, recipients) => { +      return fetcher.consumeS3Mail(s3Key, (rawMail, _) => +        inbound.deliver({ mail: new Mail(rawMail), recipients }).then(), +      );      }, -    "real-serve": async (_: unknown) => { -      await this.realServe().http.finished; +  }); + +  return { +    ...services, +    smtp, +    hono, +  }; +} + +function serve(cron: boolean = false) { +  const { config, fetcher, recycler, smtp, hono } = createServerServices(); +  smtp.serve({ +    hostname: config.get("smtpHost"), +    port: config.getInt("smtpPort"), +  }); +  Deno.serve( +    { +      hostname: config.get("httpHost"), +      port: config.getInt("httpPort"),      }, -  } as const; +    hono.fetch, +  ); + +  if (cron) { +    createCron(fetcher, recycler); +  }  } -const nonServerCli = { -  "sendmail": async (_: unknown) => { -    const decoder = new TextDecoder(); -    let text = ""; -    for await (const chunk of Deno.stdin.readable) { -      text += decoder.decode(chunk); -    } +async function listLives() { +  const { logger, fetcher } = createAwsFetchOnlyServices(); +  const liveMails = await fetcher.listLiveMails(); +  logger.info(`Total ${liveMails.length}:`); +  logger.info(liveMails.join("\n")); +} -    const res = await fetch( -      `http://localhost:${config.HTTP_PORT}/send/raw`, -      { -        method: "post", -        body: text, -      }, -    ); -    log.infoOrError(!res.ok, res); -    log.infoOrError(!res.ok, "Body\n" + await res.text()); -    if (!res.ok) Deno.exit(-1); -  }, -} as const; +async function recycleLives() { +  const { fetcher, recycler } = createAwsRecycleOnlyServices(); +  await fetcher.recycleLiveMails(recycler); +}  if (import.meta.main) {    const args = parseArgs(Deno.args); @@ -116,21 +269,32 @@ if (import.meta.main) {      throw new Error("You must specify a command.");    } -  const command = args._[0]; - -  if (command in nonServerCli) { -    log.info(`Run non-server command ${command}.`); -    await nonServerCli[command as keyof typeof nonServerCli](args); -    Deno.exit(0); -  } +  const command = String(args._[0]); -  const app = new AwsRelayApp(); -  await app.setup(); -  if (command in app.cli) { -    log.info(`Run command ${command}.`); -    await app.cli[command as keyof AwsRelayApp["cli"]](args); -    Deno.exit(0); -  } else { -    throw new Error(command + " is not a valid command."); +  switch (command) { +    case "sendmail": { +      const { logger, config } = createBaseServices(); +      await sendMail(logger, config.getInt("httpPort")); +      break; +    } +    case "list-lives": { +      await listLives(); +      break; +    } +    case "recycle-lives": { +      await recycleLives(); +      break; +    } +    case "serve": { +      serve(); +      break; +    } +    case "real-serve": { +      serve(true); +      break; +    } +    default: { +      throw new Error(command + " is not a valid command."); +    }    }  } diff --git a/deno/mail-relay/aws/context.ts b/deno/mail-relay/aws/context.ts deleted file mode 100644 index b1e0336..0000000 --- a/deno/mail-relay/aws/context.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { -  CopyObjectCommand, -  DeleteObjectCommand, -  S3Client, -} from "@aws-sdk/client-s3"; -import { FetchHttpHandler } from "@smithy/fetch-http-handler"; - -import config from "../config.ts"; - -export class AwsContext { -  readonly credentials = () => -    Promise.resolve({ -      accessKeyId: config.get("awsUser"), -      secretAccessKey: config.get("awsPassword"), -    }); -  readonly requestHandler = new FetchHttpHandler(); - -  get region() { -    return config.get("awsRegion"); -  } -} - -export async function s3MoveObject( -  client: S3Client, -  bucket: string, -  path: string, -  newPath: string, -): Promise<void> { -  const copyCommand = new CopyObjectCommand({ -    Bucket: bucket, -    Key: newPath, -    CopySource: `${bucket}/${path}`, -  }); -  await client.send(copyCommand); - -  const deleteCommand = new DeleteObjectCommand({ -    Bucket: bucket, -    Key: path, -  }); -  await client.send(deleteCommand); -} diff --git a/deno/mail-relay/aws/deliver.ts b/deno/mail-relay/aws/deliver.ts index 0db5fa8..3e1f162 100644 --- a/deno/mail-relay/aws/deliver.ts +++ b/deno/mail-relay/aws/deliver.ts @@ -1,16 +1,13 @@ -// spellchecker: words sesv2 amazonses +// spellchecker:words sesv2 amazonses -import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2"; - -import log from "../log.ts"; -import { DbService } from "../db.ts";  import { -  Mail, -  MailDeliverContext, -  MailDeliverHook, -  SyncMailDeliverer, -} from "../mail.ts"; -import { AwsContext } from "./context.ts"; +  SendEmailCommand, +  SESv2Client, +  SESv2ClientConfig, +} from "@aws-sdk/client-sesv2"; + +import { Logger } from "@crupest/base/log"; +import { Mail, MailDeliverContext, SyncMailDeliverer } from "../mail.ts";  declare module "../mail.ts" {    interface MailDeliverResult { @@ -18,61 +15,15 @@ declare module "../mail.ts" {    }  } -export class AwsMailMessageIdRewriteHook implements MailDeliverHook { -  readonly #db; - -  constructor(db: DbService) { -    this.#db = db; -  } - -  async callback(context: MailDeliverContext): Promise<void> { -    log.info("Rewrite message ids..."); -    const addresses = context.mail.simpleFindAllAddresses(); -    log.info(`Addresses found in mail: ${addresses.join(", ")}.`); -    for (const address of addresses) { -      const awsMessageId = await this.#db.messageIdToAws(address); -      if (awsMessageId != null && awsMessageId.length !== 0) { -        log.info(`Rewrite ${address} to ${awsMessageId}.`); -        context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId); -      } -    } -    log.info("Done rewrite message ids."); -  } -} - -export class AwsMailMessageIdSaveHook implements MailDeliverHook { -  readonly #db; - -  constructor(db: DbService) { -    this.#db = db; -  } - -  async callback(context: MailDeliverContext): Promise<void> { -    log.info("Save aws message ids..."); -    const messageId = context.mail.startSimpleParse().sections().headers() -      .messageId(); -    if (messageId == null) { -      log.info("Original mail does not have message id. Skip saving."); -      return; -    } -    if (context.result.awsMessageId != null) { -      log.info(`Saving ${messageId} => ${context.result.awsMessageId}.`); -      await this.#db.addMessageIdMap({ -        message_id: messageId, -        aws_message_id: context.result.awsMessageId, -      }); -    } -    log.info("Done save message ids."); -  } -} -  export class AwsMailDeliverer extends SyncMailDeliverer {    readonly name = "aws"; +  readonly #logger;    readonly #aws;    readonly #ses; -  constructor(aws: AwsContext) { -    super(); +  constructor(logger: Logger, aws: SESv2ClientConfig) { +    super(logger); +    this.#logger = logger;      this.#aws = aws;      this.#ses = new SESv2Client(aws);    } @@ -81,7 +32,7 @@ export class AwsMailDeliverer extends SyncMailDeliverer {      mail: Mail,      context: MailDeliverContext,    ): Promise<void> { -    log.info("Begin to call aws send-email api..."); +    this.#logger.info("Begin to call aws send-email api...");      try {        const sendCommand = new SendEmailCommand({ @@ -92,7 +43,7 @@ export class AwsMailDeliverer extends SyncMailDeliverer {        const res = await this.#ses.send(sendCommand);        if (res.MessageId == null) { -        log.warn("Aws send-email returns no message id."); +        this.#logger.warn("Aws send-email returns no message id.");        } else {          context.result.awsMessageId =            `${res.MessageId}@${this.#aws.region}.amazonses.com`; diff --git a/deno/mail-relay/aws/fetch.ts b/deno/mail-relay/aws/fetch.ts new file mode 100644 index 0000000..ef1ba5f --- /dev/null +++ b/deno/mail-relay/aws/fetch.ts @@ -0,0 +1,131 @@ +import { +  CopyObjectCommand, +  DeleteObjectCommand, +  GetObjectCommand, +  ListObjectsV2Command, +  S3Client, +  S3ClientConfig, +} from "@aws-sdk/client-s3"; + +import { toFileNameString } from "@crupest/base/date"; +import { Logger } from "@crupest/base/log"; + +import { Mail } from "../mail.ts"; + +async function s3MoveObject( +  client: S3Client, +  bucket: string, +  path: string, +  newPath: string, +): Promise<void> { +  const copyCommand = new CopyObjectCommand({ +    Bucket: bucket, +    Key: newPath, +    CopySource: `${bucket}/${path}`, +  }); +  await client.send(copyCommand); + +  const deleteCommand = new DeleteObjectCommand({ +    Bucket: bucket, +    Key: path, +  }); +  await client.send(deleteCommand); +} + +const AWS_SES_S3_SETUP_TAG = "AMAZON_SES_SETUP_NOTIFICATION"; + +export type AwsS3MailConsumer = ( +  rawMail: string, +  s3Key: string, +) => Promise<void>; + +export class AwsMailFetcher { +  readonly #livePrefix = "mail/live/"; +  readonly #archivePrefix = "mail/archive/"; +  readonly #logger; +  readonly #s3; +  readonly #bucket; + +  constructor(logger: Logger, aws: S3ClientConfig, bucket: string) { +    this.#logger = logger; +    this.#s3 = new S3Client(aws); +    this.#bucket = bucket; +  } + +  async listLiveMails(): Promise<string[]> { +    this.#logger.info("Begin to retrieve live mails."); + +    const listCommand = new ListObjectsV2Command({ +      Bucket: this.#bucket, +      Prefix: this.#livePrefix, +    }); +    const res = await this.#s3.send(listCommand); + +    if (res.Contents == null) { +      this.#logger.warn("Listing live mails in S3 returns null Content."); +      return []; +    } + +    const result: string[] = []; +    for (const object of res.Contents) { +      if (object.Key == null) { +        this.#logger.warn( +          "Listing live mails in S3 returns an object with no Key.", +        ); +        continue; +      } + +      if (object.Key.endsWith(AWS_SES_S3_SETUP_TAG)) continue; + +      result.push(object.Key.slice(this.#livePrefix.length)); +    } +    return result; +  } + +  async consumeS3Mail(s3Key: string, consumer: AwsS3MailConsumer) { +    this.#logger.info(`Begin to consume s3 mail ${s3Key} ...`); + +    this.#logger.info(`Fetching s3 mail ${s3Key}...`); +    const mailPath = `${this.#livePrefix}${s3Key}`; +    const command = new GetObjectCommand({ +      Bucket: this.#bucket, +      Key: mailPath, +    }); +    const res = await this.#s3.send(command); + +    if (res.Body == null) { +      throw new Error("S3 mail returns a null body."); +    } + +    const rawMail = await res.Body.transformToString(); +    this.#logger.info(`Done fetching s3 mail ${s3Key}.`); + +    this.#logger.info(`Calling consumer...`); +    await consumer(rawMail, s3Key); +    this.#logger.info(`Done consuming s3 mail ${s3Key}.`); + +    const date = new Mail(rawMail) +      .startSimpleParse(this.#logger) +      .sections() +      .headers() +      .date(); +    const dateString = +      date != null ? toFileNameString(date, true) : "invalid-date"; +    const newPath = `${this.#archivePrefix}${dateString}/${s3Key}`; + +    this.#logger.info(`Archiving s3 mail ${s3Key} to ${newPath}...`); +    await s3MoveObject(this.#s3, this.#bucket, mailPath, newPath); +    this.#logger.info(`Done archiving s3 mail ${s3Key}.`); + +    this.#logger.info(`Done consuming s3 mail ${s3Key}.`); +  } + +  async recycleLiveMails(consumer: AwsS3MailConsumer) { +    this.#logger.info("Begin to recycle live mails..."); +    const mails = await this.listLiveMails(); +    this.#logger.info(`Found ${mails.length} live mails`); +    for (const s3Key of mails) { +      await this.consumeS3Mail(s3Key, consumer); +    } +  } +} diff --git a/deno/mail-relay/aws/mail.ts b/deno/mail-relay/aws/mail.ts new file mode 100644 index 0000000..d2cfad1 --- /dev/null +++ b/deno/mail-relay/aws/mail.ts @@ -0,0 +1,53 @@ +import { MailDeliverContext, MailDeliverHook } from "../mail.ts"; + +export class AwsMailMessageIdRewriteHook implements MailDeliverHook { +  readonly #lookup; + +  constructor(lookup: (origin: string) => Promise<string | null>) { +    this.#lookup = lookup; +  } + +  async callback(context: MailDeliverContext): Promise<void> { +    context.logger.info("Rewrite message ids..."); +    const addresses = context.mail.simpleFindAllAddresses(); +    context.logger.info(`Addresses found in mail: ${addresses.join(", ")}.`); +    for (const address of addresses) { +      const awsMessageId = await this.#lookup(address); +      if (awsMessageId != null && awsMessageId.length !== 0) { +        context.logger.info(`Rewrite ${address} to ${awsMessageId}.`); +        context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId); +      } +    } +    context.logger.info("Done rewrite message ids."); +  } +} + +export class AwsMailMessageIdSaveHook implements MailDeliverHook { +  readonly #record; + +  constructor(record: (original: string, aws: string) => Promise<void>) { +    this.#record = record; +  } + +  async callback(context: MailDeliverContext): Promise<void> { +    context.logger.info("Save aws message ids..."); +    const messageId = context.mail +      .startSimpleParse(context.logger) +      .sections() +      .headers() +      .messageId(); +    if (messageId == null) { +      context.logger.info( +        "Original mail does not have message id. Skip saving.", +      ); +      return; +    } +    if (context.result.awsMessageId != null) { +      context.logger.info( +        `Saving ${messageId} => ${context.result.awsMessageId}.`, +      ); +      await this.#record(messageId, context.result.awsMessageId); +    } +    context.logger.info("Done save message ids."); +  } +} diff --git a/deno/mail-relay/aws/retriever.ts b/deno/mail-relay/aws/retriever.ts deleted file mode 100644 index 756cfc3..0000000 --- a/deno/mail-relay/aws/retriever.ts +++ /dev/null @@ -1,100 +0,0 @@ -/// <reference types="npm:@types/node" /> - -import { -  GetObjectCommand, -  ListObjectsV2Command, -  S3Client, -} from "@aws-sdk/client-s3"; - -import log from "../log.ts"; -import config from "../config.ts"; -import "../better-js.ts"; - -import { Mail, MailDeliverer } from "../mail.ts"; -import { AwsContext, s3MoveObject } from "./context.ts"; - -const AWS_SES_S3_SETUP_TAG = "AMAZON_SES_SETUP_NOTIFICATION"; - -export class AwsMailRetriever { -  readonly liveMailPrefix = "mail/live/"; -  readonly archiveMailPrefix = "mail/archive/"; -  readonly mailBucket = config.get("awsMailBucket"); - -  readonly #s3; - -  constructor( -    aws: AwsContext, -    public readonly inboundDeliverer: MailDeliverer, -  ) { -    this.#s3 = new S3Client(aws); -  } - -  async listLiveMails(): Promise<string[]> { -    log.info("Begin to retrieve live mails."); - -    const listCommand = new ListObjectsV2Command({ -      Bucket: this.mailBucket, -      Prefix: this.liveMailPrefix, -    }); -    const res = await this.#s3.send(listCommand); - -    if (res.Contents == null) { -      log.warn("Listing live mails in S3 returns null Content."); -      return []; -    } - -    const result: string[] = []; -    for (const object of res.Contents) { -      if (object.Key == null) { -        log.warn("Listing live mails in S3 returns an object with no Key."); -        continue; -      } - -      if (object.Key.endsWith(AWS_SES_S3_SETUP_TAG)) continue; - -      result.push(object.Key.slice(this.liveMailPrefix.length)); -    } -    return result; -  } - -  async deliverS3Mail(s3Key: string, recipients: string[] = []) { -    log.info(`Begin to deliver s3 mail ${s3Key} to ${recipients.join(" ")}...`); - -    log.info(`Fetching s3 mail ${s3Key}...`); -    const mailPath = `${this.liveMailPrefix}${s3Key}`; -    const command = new GetObjectCommand({ -      Bucket: this.mailBucket, -      Key: mailPath, -    }); -    const res = await this.#s3.send(command); - -    if (res.Body == null) { -      throw new Error("S3 mail returns a null body."); -    } - -    const rawMail = await res.Body.transformToString(); -    log.info(`Done fetching s3 mail ${s3Key}.`); - -    log.info(`Delivering s3 mail ${s3Key}...`); -    const mail = new Mail(rawMail); -    await this.inboundDeliverer.deliver({ mail, recipients: recipients }); -    log.info(`Done delivering s3 mail ${s3Key}.`); - -    const date = mail.startSimpleParse().sections().headers().date(); -    const dateString = date?.toFileNameString(true) ?? "invalid-date"; -    const newPath = `${this.archiveMailPrefix}${dateString}/${s3Key}`; - -    log.info(`Archiving s3 mail ${s3Key} to ${newPath}...`); -    await s3MoveObject(this.#s3, this.mailBucket, mailPath, newPath); -    log.info(`Done delivering s3 mail ${s3Key}...`); -  } - -  async recycleLiveMails() { -    log.info("Begin to recycle live mails..."); -    const mails = await this.listLiveMails(); -    log.info(`Found ${mails.length} live mails`); -    for (const s3Key of mails) { -      await this.deliverS3Mail(s3Key); -    } -  } -}  | 
