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.ts126
-rw-r--r--services/docker/mail-server/relay/aws/context.ts30
-rw-r--r--services/docker/mail-server/relay/aws/deliver.ts43
-rw-r--r--services/docker/mail-server/relay/aws/mail.ts1
-rw-r--r--services/docker/mail-server/relay/aws/retriever.ts70
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);
}