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); - } - } -} |