diff options
| author | Yuqian Yang <crupest@crupest.life> | 2025-04-10 15:12:46 +0800 | 
|---|---|---|
| committer | crupest <crupest@outlook.com> | 2025-06-02 18:04:47 +0800 | 
| commit | 3701a54d1c71c166c18113d05a44ab7c932557b5 (patch) | |
| tree | 0c20207992687326dee4151541aa4110f57ce97e /services/docker/mail-server/relay/aws | |
| parent | f43a156d83c841e0f6c3cd921b2e3e34936231cc (diff) | |
| download | crupest-3701a54d1c71c166c18113d05a44ab7c932557b5.tar.gz crupest-3701a54d1c71c166c18113d05a44ab7c932557b5.tar.bz2 crupest-3701a54d1c71c166c18113d05a44ab7c932557b5.zip | |
feat(mail-server): done (except aws message-id map).
Diffstat (limited to 'services/docker/mail-server/relay/aws')
| -rw-r--r-- | services/docker/mail-server/relay/aws/app.ts | 124 | ||||
| -rw-r--r-- | services/docker/mail-server/relay/aws/context.ts | 41 | ||||
| -rw-r--r-- | services/docker/mail-server/relay/aws/deliver.ts | 52 | ||||
| -rw-r--r-- | services/docker/mail-server/relay/aws/mail.ts | 7 | ||||
| -rw-r--r-- | services/docker/mail-server/relay/aws/retriever.ts | 100 | 
5 files changed, 324 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..9135d21 --- /dev/null +++ b/services/docker/mail-server/relay/aws/app.ts @@ -0,0 +1,124 @@ +import { parseArgs } from "@std/cli"; +import { z } from "zod"; +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"; +import { AwsMailRetriever } from "./retriever.ts"; + +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( +      `/${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.array(z.string()).optional(), +        }), +      ), +      async (ctx) => { +        const { key, recipients } = ctx.req.valid("json"); +        await this.#retriever.deliverS3Mail(key, recipients); +        return ctx.json({ "msg": "Done!" }); +      }, +    ); +  } + +  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.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 new file mode 100644 index 0000000..b1e0336 --- /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 config from "../config.ts"; + +export class AwsContext { +  readonly credentials = () => +    Promise.resolve({ +      accessKeyId: config.get("awsUser"), +      secretAccessKey: config.get("awsPassword"), +    }); +  readonly requestHandler = new FetchHttpHandler(); + +  get region() { +    return config.get("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..9c62496 --- /dev/null +++ b/services/docker/mail-server/relay/aws/deliver.ts @@ -0,0 +1,52 @@ +import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2"; + +import { AwsContext } from "./context.ts"; +import { +  Mail, +  MailDeliverContext, +  MailDeliverer, +  MailDeliverRecipientResult, +} from "./mail.ts"; +import log from "../log.ts"; + +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.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({ +        Content: { +          Raw: { Data: mail.toUtf8Bytes() }, +        }, +      }); + +      const res = await this.#ses.send(sendCommand); +      if (res.MessageId == null) { +        log.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..daa3d8c --- /dev/null +++ b/services/docker/mail-server/relay/aws/retriever.ts @@ -0,0 +1,100 @@ +/// <reference types="npm:@types/node" /> + +import { +  GetObjectCommand, +  ListObjectsV2Command, +  S3Client, +} from "@aws-sdk/client-s3"; + +import log from "../log.ts"; +import config from "../config.ts"; +import "../better-js.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 = config.get("awsMailBucket"); + +  readonly #s3; + +  constructor( +    aws: AwsContext, +    public readonly inboundDeliverer: MailDeliverer, +  ) { +    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, +    }); +    const res = await this.#s3.send(listCommand); + +    if (res.Contents == null) { +      log.warn("Listing live mails in S3 returns null Content."); +      return []; +    } + +    const result: string[] = []; +    for (const object of res.Contents) { +      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, 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, +      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.info(`Done fetching s3 mail ${s3Key}.`); + +    log.info(`Delivering s3 mail ${s3Key}...`); +    const mail = new Mail(rawMail); +    await this.inboundDeliverer.deliver({ mail, recipients: recipients }); +    log.info(`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.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); +    } +  } +} | 
