diff options
Diffstat (limited to 'services/docker/mail-server/relay/aws')
-rw-r--r-- | services/docker/mail-server/relay/aws/app.ts | 223 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/context.ts | 41 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/deliver.ts | 53 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/mail.ts | 7 | ||||
-rw-r--r-- | services/docker/mail-server/relay/aws/retriever.ts | 99 |
5 files changed, 423 insertions, 0 deletions
diff --git a/services/docker/mail-server/relay/aws/app.ts b/services/docker/mail-server/relay/aws/app.ts new file mode 100644 index 0000000..76e2793 --- /dev/null +++ b/services/docker/mail-server/relay/aws/app.ts @@ -0,0 +1,223 @@ +import { parseArgs } from "@std/cli"; +import { decodeBase64 } from "@std/encoding/base64"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; + +import { error, log } from "../logger.ts"; +import { AppBase } from "../app.ts"; +import { AwsContext } from "./context.ts"; +import { AwsMailDeliverer } from "./deliver.ts"; +import { AwsMailRetriever } from "./retriever.ts"; +import config from "../config.ts"; + +const AWS_SNS_MESSAGE_MODEL = z.object({ + Type: z.enum(["Notification", "SubscriptionConfirmation"]), + TopicArn: z.string(), + Timestamp: z.string(), + Subject: z.string().optional(), + SubscribeURL: z.string().optional(), + Message: z.string(), + MessageId: z.string(), + Signature: z.string(), + SigningCertURL: z.string(), + SignatureVersion: z.string(), +}); + +type AwsSnsMessage = z.TypeOf<typeof AWS_SNS_MESSAGE_MODEL>; + +const AWS_SES_SNS_MESSAGE_MODEL = z.object({ + notificationType: z.literal("Received").or(z.string()), + receipt: z.object({ + recipients: z.array(z.string()), + }), + mail: z.object({ + messageId: z.string(), + }), +}); + +const AWS_SNS_SIGNATURE_FIELDS = { + Notification: [ + "Message", + "MessageId", + "Subject", + "Timestamp", + "TopicArn", + "Type", + ], + SubscriptionConfirmation: [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "TopicArn", + "Type", + ], +} as const; + +async function verifySnsSignature(message: AwsSnsMessage) { + const signingCertUrl = message.SigningCertURL; + + if (!new URL(signingCertUrl).hostname.endsWith(".amazonaws.com")) { + throw new Error( + `Signature cert url ${signingCertUrl} does not belong to aws!!!`, + ); + } + + const signature = message.Signature; + const data = AWS_SNS_SIGNATURE_FIELDS[message.Type].filter((field) => + field in message + ).flatMap((field) => [field, message[field]]).join("\n"); + const certData = await (await fetch(signingCertUrl)).bytes(); + const key = await crypto.subtle.importKey( + "pkcs8", + certData, + { + name: "RSA-PSS", + hash: message.SignatureVersion === "1" ? "SHA-1" : "SHA-256", + }, + false, + ["verify"], + ); + const isVerified = await crypto.subtle.verify( + { + name: "RSA-PSS", + hash: message.SignatureVersion === "1" ? "SHA-1" : "SHA-256", + }, + key, + decodeBase64(signature), + new TextEncoder().encode(data), + ); + + if (!isVerified) { + throw new Error("Signature does not match!!!"); + } +} + +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.hono.post( + "/receive/s3", + zValidator( + "json", + z.object({ + key: z.string(), + }), + ), + async (ctx) => { + await this.#retriever.deliverS3Mail( + ctx.req.valid("json").key, + ); + return ctx.json({ + "msg": "Done!", + }); + }, + ); + this.hono.post( + `/receive/aws-sns/${config.getValue("awsInboundPath")}`, + zValidator("json", AWS_SNS_MESSAGE_MODEL), + async (ctx) => { + const message = ctx.req.valid("json"); + await verifySnsSignature(message); + if (message.Type === "Notification") { + const sesMessage = JSON.parse(message.Message); + const parsedSesMessage = AWS_SES_SNS_MESSAGE_MODEL.parse(sesMessage); + // TODO: Here!!! Specify receipts! + await this.#retriever.deliverS3Mail(parsedSesMessage.mail.messageId); + return ctx.json({ + "msg": "Done!", + }); + } else if (message.Type === "SubscriptionConfirmation") { + } else { + } + }, + ); + } + + realServe() { + this.createCron({ + name: "live-mail-recycler", + interval: 6 * 3600 * 1000, + callback: () => { + return this.#retriever.recycleLiveMails(); + }, + startNow: true, + }); + + return this.serve(); + } + + readonly cli = { + "init": (_: unknown) => { + log("Just init!"); + return Promise.resolve(); + }, + "list-lives": async (_: unknown) => { + const liveMails = await this.#retriever.listLiveMails(); + log(`Total ${liveMails.length}:`); + log(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, + }, + ); + const logger = res.ok ? log : error; + logger(res); + logger("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(`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(`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 new file mode 100644 index 0000000..c4f67bc --- /dev/null +++ b/services/docker/mail-server/relay/aws/context.ts @@ -0,0 +1,41 @@ +import { + CopyObjectCommand, + DeleteObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { FetchHttpHandler } from "@smithy/fetch-http-handler"; + +import { getConfigValue } from "../config.ts"; + +export class AwsContext { + readonly credentials = () => + Promise.resolve({ + accessKeyId: getConfigValue("awsAccessKeyId"), + secretAccessKey: getConfigValue("awsSecretAccessKey"), + }); + readonly requestHandler = new FetchHttpHandler(); + + get region() { + return getConfigValue("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/services/docker/mail-server/relay/aws/deliver.ts b/services/docker/mail-server/relay/aws/deliver.ts new file mode 100644 index 0000000..793c85a --- /dev/null +++ b/services/docker/mail-server/relay/aws/deliver.ts @@ -0,0 +1,53 @@ +import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2"; + +import { AwsContext } from "./context.ts"; +import { + Mail, + MailDeliverContext, + MailDeliverer, + MailDeliverReceiptResult, +} from "./mail.ts"; +import { warn } from "../logger.ts"; +import { log } from "node:console"; + +export class AwsMailDeliverer extends MailDeliverer { + readonly name = "aws"; + readonly #ses; + + constructor(aws: AwsContext) { + super(); + this.#ses = new SESv2Client(aws); + } + + protected override async doDeliver( + mail: Mail, + context: MailDeliverContext, + ): Promise<void> { + log("Begin to call aws send-email api..."); + + const result: MailDeliverReceiptResult = { + kind: "done", + message: "Success to call send-email api of aws.", + }; + + try { + const sendCommand = new SendEmailCommand({ + Content: { + Raw: { Data: mail.toUtf8Bytes() }, + }, + }); + + const res = await this.#ses.send(sendCommand); + if (res.MessageId == null) { + warn("Aws send-email returns no message id."); + } + mail.awsMessageId = res.MessageId; + } catch (cause) { + result.kind = "fail"; + result.message = "An error was thrown when calling aws send-email." + + cause; + result.cause = cause; + } + context.result.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..a0f2639 --- /dev/null +++ b/services/docker/mail-server/relay/aws/mail.ts @@ -0,0 +1,7 @@ +export * from "../mail.ts"; + +declare module "../mail.ts" { + interface Mail { + awsMessageId?: string; + } +} diff --git a/services/docker/mail-server/relay/aws/retriever.ts b/services/docker/mail-server/relay/aws/retriever.ts new file mode 100644 index 0000000..b81a8f6 --- /dev/null +++ b/services/docker/mail-server/relay/aws/retriever.ts @@ -0,0 +1,99 @@ +/// <reference types="npm:@types/node" /> + +import { + GetObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; + +import { log, warn } from "../logger.ts"; +import { getConfigValue } from "../config.ts"; +import "../util.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 = getConfigValue("awsMailBucket"); + + readonly #s3; + + constructor( + aws: AwsContext, + public readonly inboundDeliverer: MailDeliverer, + ) { + this.#s3 = new S3Client(aws); + } + + async listLiveMails(): Promise<string[]> { + log("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) { + warn("Listing live mails in S3 returns null Content."); + return []; + } + + const result: string[] = []; + for (const object of res.Contents) { + if (object.Key == null) { + 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) { + log(`Begin to deliver s3 mail ${s3Key}...`); + + log(`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(`Done fetching s3 mail ${s3Key}.`); + + log(`Delivering s3 mail ${s3Key}...`); + const mail = new Mail(rawMail); + await this.inboundDeliverer.deliver(mail); + log(`Done delivering s3 mail ${s3Key}.`); + + const date = mail.date ?? mail.simpleParseDate(); + const dateString = date?.toFileNameString(true) ?? "invalid-date"; + const newPath = `${this.archiveMailPrefix}${dateString}/${s3Key}`; + + log(`Archiving s3 mail ${s3Key} to ${newPath}...`); + await s3MoveObject(this.#s3, this.mailBucket, mailPath, newPath); + log(`Done delivering s3 mail ${s3Key}...`); + } + + async recycleLiveMails() { + log("Begin to recycle live mails..."); + const mails = await this.listLiveMails(); + log(`Found ${mails.length} live mails`); + for (const s3Key of mails) { + await this.deliverS3Mail(s3Key); + } + } +} |