diff options
Diffstat (limited to 'services/docker/mail-server/relay/aws')
-rw-r--r-- | services/docker/mail-server/relay/aws/app.ts | 126 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/context.ts | 30 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/deliver.ts | 43 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/mail.ts | 1 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/retriever.ts | 70 |
5 files changed, 180 insertions, 90 deletions
diff --git a/services/docker/mail-server/relay/aws/app.ts b/services/docker/mail-server/relay/aws/app.ts index 251d151..4b60853 100644 --- a/services/docker/mail-server/relay/aws/app.ts +++ b/services/docker/mail-server/relay/aws/app.ts @@ -1,7 +1,9 @@ +import { parseArgs } from "@std/cli"; import { z } from "zod"; -import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; +import log from "../log.ts"; +import config from "../config.ts"; import { AppBase } from "../app.ts"; import { AwsContext } from "./context.ts"; import { AwsMailDeliverer } from "./deliver.ts"; @@ -10,39 +12,113 @@ import { AwsMailRetriever } from "./retriever.ts"; export class AwsRelayApp extends AppBase { readonly #aws = new AwsContext(); readonly #retriever; - readonly #liveMailRecyclerAborter = new AbortController(); protected readonly outboundDeliverer = new AwsMailDeliverer(this.#aws); constructor() { super(); this.#retriever = new AwsMailRetriever(this.#aws, this.inboundDeliverer); - const hono = new Hono() - .post( - "/receive/s3", - zValidator( - "json", - z.object({ - key: z.string(), - }), - ), - async (ctx) => { - await this.#retriever.deliverS3Mail( - ctx.req.valid("json").key, - ); - }, - ); - - this.routes.push(hono); + 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!" }); + }, + ); } - override run(): Promise<void> { - Deno.cron("live-mail-recycler", "0 */6 * * *", { - signal: this.#liveMailRecyclerAborter.signal, - }, () => { - this.#retriever.recycleLiveMails(); + realServe() { + this.createCron({ + name: "live-mail-recycler", + interval: 6 * 3600 * 1000, + callback: () => { + return this.#retriever.recycleLiveMails(); + }, + startNow: true, }); - return super.run(); + return this.serve(); + } + + readonly cli = { + "init": (_: unknown) => { + log.info("Just init!"); + return Promise.resolve(); + }, + "list-lives": async (_: unknown) => { + const liveMails = await this.#retriever.listLiveMails(); + log.info(`Total ${liveMails.length}:`); + log.info(liveMails.join("\n")); + }, + "recycle-lives": async (_: unknown) => { + await this.#retriever.recycleLiveMails(); + }, + "serve": async (_: unknown) => { + await this.serve().http.finished; + }, + "real-serve": async (_: unknown) => { + await this.realServe().http.finished; + }, + } as const; +} + +const nonServerCli = { + "sendmail": async (_: unknown) => { + const decoder = new TextDecoder(); + let text = ""; + for await (const chunk of Deno.stdin.readable) { + text += decoder.decode(chunk); + } + + 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; + +if (import.meta.main) { + const args = parseArgs(Deno.args); + + if (args._.length === 0) { + 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 app = new AwsRelayApp(); + 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."); } } diff --git a/services/docker/mail-server/relay/aws/context.ts b/services/docker/mail-server/relay/aws/context.ts index 34d2d9f..b1e0336 100644 --- a/services/docker/mail-server/relay/aws/context.ts +++ b/services/docker/mail-server/relay/aws/context.ts @@ -3,27 +3,21 @@ import { DeleteObjectCommand, S3Client, } from "@aws-sdk/client-s3"; +import { FetchHttpHandler } from "@smithy/fetch-http-handler"; -import { getConfig } from "../config.ts"; - -declare module "../mail.ts" { - interface Mail { - awsMessageId?: string; - } -} +import config from "../config.ts"; export class AwsContext { - readonly region = "ap-southeast-1"; - - accessKeyId = getConfig().getValue("awsAccessKeyId"); - secretAccessKey = getConfig().getValue("awsSecretAccessKey"); - - getCredentials() { - const { accessKeyId, secretAccessKey } = this; - return Promise.resolve({ accessKeyId, secretAccessKey }); + readonly credentials = () => + Promise.resolve({ + accessKeyId: config.get("awsUser"), + secretAccessKey: config.get("awsPassword"), + }); + readonly requestHandler = new FetchHttpHandler(); + + get region() { + return config.get("awsRegion"); } - - readonly credentials = this.getCredentials.bind(this); } export async function s3MoveObject( @@ -35,7 +29,7 @@ export async function s3MoveObject( const copyCommand = new CopyObjectCommand({ Bucket: bucket, Key: newPath, - CopySource: path, + CopySource: `${bucket}/${path}`, }); await client.send(copyCommand); diff --git a/services/docker/mail-server/relay/aws/deliver.ts b/services/docker/mail-server/relay/aws/deliver.ts index 93e4954..5b3694f 100644 --- a/services/docker/mail-server/relay/aws/deliver.ts +++ b/services/docker/mail-server/relay/aws/deliver.ts @@ -1,7 +1,13 @@ import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2"; import { AwsContext } from "./context.ts"; -import { Mail, MailDeliverer } from "../mail.ts"; +import { + Mail, + MailDeliverContext, + MailDeliverer, + MailDeliverRecipientResult, +} from "./mail.ts"; +import log from "../log.ts"; export class AwsMailDeliverer extends MailDeliverer { readonly name = "aws"; @@ -9,12 +15,19 @@ export class AwsMailDeliverer extends MailDeliverer { constructor(aws: AwsContext) { super(); - const { region, credentials } = aws; - this.#ses = new SESv2Client({ region, credentials }); + this.#ses = new SESv2Client(aws); } - protected override async doDeliver(mail: Mail): Promise<void> { - let awsMessageId: string | undefined; + protected override async doDeliver( + mail: Mail, + context: MailDeliverContext, + ): Promise<void> { + log.info("Begin to call aws send-email api..."); + + const result: MailDeliverRecipientResult = { + kind: "done", + message: "Success to call send-email api of aws.", + }; try { const sendCommand = new SendEmailCommand({ @@ -24,18 +37,16 @@ export class AwsMailDeliverer extends MailDeliverer { }); const res = await this.#ses.send(sendCommand); - awsMessageId = res.MessageId; + if (res.MessageId == null) { + log.warn("Aws send-email returns no message id."); + } + mail.awsMessageId = res.MessageId; } catch (cause) { - mail.throwDeliverError( - this, - "failed to call send-email api of aws.", - cause, - ); - } - - if (awsMessageId == null) { - mail.setDelivered(this, new Error("No message id is returned from aws.")); + result.kind = "fail"; + result.message = "An error was thrown when calling aws send-email." + + cause; + result.cause = cause; } - mail.awsMessageId = awsMessageId; + context.result.recipients.set("*", result); } } diff --git a/services/docker/mail-server/relay/aws/mail.ts b/services/docker/mail-server/relay/aws/mail.ts new file mode 100644 index 0000000..9c2e73b --- /dev/null +++ b/services/docker/mail-server/relay/aws/mail.ts @@ -0,0 +1 @@ +export * from "../mail.ts"; diff --git a/services/docker/mail-server/relay/aws/retriever.ts b/services/docker/mail-server/relay/aws/retriever.ts index 2ee5643..756cfc3 100644 --- a/services/docker/mail-server/relay/aws/retriever.ts +++ b/services/docker/mail-server/relay/aws/retriever.ts @@ -6,16 +6,19 @@ import { S3Client, } from "@aws-sdk/client-s3"; -import { AwsContext, s3MoveObject } from "./context.ts"; -import { getLogger } from "../logger.ts"; -import { getConfig } from "../config.ts"; +import log from "../log.ts"; +import config from "../config.ts"; +import "../better-js.ts"; + import { Mail, MailDeliverer } from "../mail.ts"; -import { generateTimeStringForFileName } from "../util.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 = getConfig().getValue("awsMailBucket"); + readonly mailBucket = config.get("awsMailBucket"); readonly #s3; @@ -23,11 +26,12 @@ export class AwsMailRetriever { aws: AwsContext, public readonly inboundDeliverer: MailDeliverer, ) { - const { region, credentials } = aws; - this.#s3 = new S3Client({ region, credentials }); + 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, @@ -35,25 +39,28 @@ export class AwsMailRetriever { const res = await this.#s3.send(listCommand); if (res.Contents == null) { - getLogger().warn("Listing live mails in S3 returns null Content."); + log.warn("Listing live mails in S3 returns null Content."); return []; } const result: string[] = []; for (const object of res.Contents) { - if (object.Key != null) { - // TODO: check prefix consistence here. - result.push(object.Key.slice(this.liveMailPrefix.length)); - } else { - getLogger().warn( - "Listing live mails in S3 returns an object with no Key.", - ); + 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) { + 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, @@ -62,29 +69,30 @@ export class AwsMailRetriever { const res = await this.#s3.send(command); if (res.Body == null) { - // TODO: Better error. - throw new Error(); + 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); - const date = mail.date ?? mail.simpleParseDate(); - const dateString = date != null - ? generateTimeStringForFileName(date, true) - : "invalid-date"; - - // TODO: Continue here. - await s3MoveObject( - this.#s3, - this.mailBucket, - mailPath, - `${this.archiveMailPrefix}${dateString}/${s3Key}`, - ); + 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); } |