diff options
Diffstat (limited to 'services/docker/mail-server/relay/aws/app.ts')
-rw-r--r-- | services/docker/mail-server/relay/aws/app.ts | 223 |
1 files changed, 223 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."); + } +} |