aboutsummaryrefslogtreecommitdiff
path: root/services/docker/mail-server/relay/aws
diff options
context:
space:
mode:
Diffstat (limited to 'services/docker/mail-server/relay/aws')
-rw-r--r--services/docker/mail-server/relay/aws/app.ts22
-rw-r--r--services/docker/mail-server/relay/aws/context.ts41
-rw-r--r--services/docker/mail-server/relay/aws/deliver.ts42
-rw-r--r--services/docker/mail-server/relay/aws/retriever.ts90
-rw-r--r--services/docker/mail-server/relay/aws/service.ts48
5 files changed, 243 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..e25c92c
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/app.ts
@@ -0,0 +1,22 @@
+import { Hono } from "https://jsr.io/@hono/hono/4.7.9/src/hono.ts";
+import { AppBase } from "../app.ts";
+import { MailDeliverer } from "../mail.ts";
+import { AwsContext } from "./context.ts";
+import { AwsMailDeliverer } from "./deliver.ts";
+
+export class AwsRelayApp extends AppBase {
+ private readonly context = new AwsContext();
+ private readonly deliverer = new AwsMailDeliverer(this.context);
+
+ constructor() {
+ super();
+ }
+
+ protected override setupHono(_hono: Hono): Promise<void> {
+
+ }
+
+ protected override get outboundMailDeliverer(): MailDeliverer {
+ return this.deliverer;
+ }
+}
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..65c8371
--- /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 { getConfig } 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 = this.getCredentials.bind(this);
+}
+
+export async function s3MoveObject(
+ client: S3Client,
+ bucket: string,
+ path: string,
+ newPath: string,
+): Promise<void> {
+ const copyCommand = new CopyObjectCommand({
+ Bucket: bucket,
+ Key: newPath,
+ CopySource: 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..5982d56
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/deliver.ts
@@ -0,0 +1,42 @@
+import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2";
+
+import { AwsContext } from "./context.ts";
+import { Mail, MailDeliverer } from "../mail.ts";
+
+export class AwsMailDeliverer extends MailDeliverer {
+ private _ses;
+
+ constructor(readonly aws: AwsContext) {
+ super("aws");
+
+ const { region, credentials } = aws;
+
+ this._ses = new SESv2Client({ region, credentials });
+ }
+
+ protected override async doDeliver(mail: Mail): Promise<void> {
+ let awsMessageId: string | undefined;
+
+ try {
+ const sendCommand = new SendEmailCommand({
+ Content: {
+ Raw: { Data: mail.toUtf8Bytes() },
+ },
+ });
+
+ const res = await this._ses.send(sendCommand);
+ 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."));
+ }
+ mail.awsMessageId = awsMessageId ?? null;
+ }
+}
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..de577b0
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/retriever.ts
@@ -0,0 +1,90 @@
+/// <reference types="npm:@types/node" />
+
+import {
+ GetObjectCommand,
+ ListObjectsV2Command,
+ S3Client,
+} from "@aws-sdk/client-s3";
+
+import { AwsContext, s3MoveObject } from "./context.ts";
+import { getLogger } from "../logger.ts";
+import { getConfig } from "../config.ts";
+import { Mail } from "../mail.ts";
+
+export class AwsMailRetriever {
+ readonly liveMailPrefix = "mail/live/";
+ readonly archiveMailPrefix = "mail/archive/";
+ readonly mailBucket = getConfig().getValue("awsMailBucket");
+
+ private readonly s3Client;
+
+ constructor(
+ aws: AwsContext,
+ private readonly callback: (mail: Mail) => Promise<void>,
+ ) {
+ const { region, credentials } = aws;
+ this.s3Client = new S3Client({ region, credentials });
+ }
+
+ async listLiveMails(): Promise<string[]> {
+ const listCommand = new ListObjectsV2Command({
+ Bucket: this.mailBucket,
+ Prefix: this.liveMailPrefix,
+ });
+ const res = await this.s3Client.send(listCommand);
+
+ if (res.Contents == null) {
+ getLogger().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.",
+ );
+ }
+ }
+ return result;
+ }
+
+ async deliverS3MailObject(messageId: string) {
+ const mailPath = `${this.liveMailPrefix}${messageId}`;
+ const command = new GetObjectCommand({
+ Bucket: this.mailBucket,
+ Key: mailPath,
+ });
+ const res = await this.s3Client.send(command);
+
+ if (res.Body == null) {
+ // TODO: Better error.
+ throw new Error();
+ }
+
+ const rawMail = await res.Body.transformToString();
+ const mail = new Mail(rawMail);
+ mail.awsMessageId = messageId;
+ await this.callback(mail);
+
+ // TODO: Continue here.
+ await s3MoveObject(
+ this.s3Client,
+ this.mailBucket,
+ mailPath,
+ `${this.archiveMailPrefix}${
+ mail.simpleGetDateString("invalid-date")
+ }/${messageId}`,
+ );
+ }
+
+ async recycleLiveMails() {
+ const mails = await this.listLiveMails();
+ for (const messageId of mails) {
+ await this.deliverS3MailObject(messageId);
+ }
+ }
+}
diff --git a/services/docker/mail-server/relay/aws/service.ts b/services/docker/mail-server/relay/aws/service.ts
new file mode 100644
index 0000000..d0db7ae
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/service.ts
@@ -0,0 +1,48 @@
+import { Mail } from "../mail.ts";
+import { AwsContext } from "./context.ts";
+import { AwsMailRetriever } from "./retriever.ts";
+
+export interface AwsServiceSetupOptions {
+ receiveCallback: (mail: Mail) => Promise<void>;
+}
+
+interface Setup {
+ receiveCallback: (mail: Mail) => Promise<void>;
+ retriever: AwsMailRetriever;
+ liveMailRecyclerAborter: AbortController;
+}
+
+export class AwsService implements Disposable {
+ private _setup: Setup | null = null;
+
+ constructor(private readonly aws: AwsContext) {}
+
+ setup(options: AwsServiceSetupOptions): Disposable {
+ if (this._setup != null) {
+ // TODO: Better error.
+ throw new Error("Aws service has already been set up.");
+ }
+ const { receiveCallback } = options;
+ const liveMailRecyclerAborter = new AbortController();
+ const retriever = new AwsMailRetriever(this.aws, receiveCallback);
+
+ Deno.cron("live-mail-recycler", "0 */6 * * *", {
+ signal: liveMailRecyclerAborter.signal,
+ }, () => {
+ retriever.recycleLiveMails();
+ });
+
+ this._setup = {
+ receiveCallback,
+ retriever,
+ liveMailRecyclerAborter,
+ };
+
+ return this;
+ }
+
+ [Symbol.dispose]() {
+ if (this._setup == null) return;
+ this._setup.liveMailRecyclerAborter.abort("Aws service is being disposed.");
+ }
+}