diff options
Diffstat (limited to 'deno/mail-relay/aws')
| -rw-r--r-- | deno/mail-relay/aws/app.ts | 136 | ||||
| -rw-r--r-- | deno/mail-relay/aws/context.ts | 41 | ||||
| -rw-r--r-- | deno/mail-relay/aws/deliver.ts | 114 | ||||
| -rw-r--r-- | deno/mail-relay/aws/retriever.ts | 100 | 
4 files changed, 391 insertions, 0 deletions
diff --git a/deno/mail-relay/aws/app.ts b/deno/mail-relay/aws/app.ts new file mode 100644 index 0000000..1fda64e --- /dev/null +++ b/deno/mail-relay/aws/app.ts @@ -0,0 +1,136 @@ +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, +  AwsMailMessageIdRewriteHook, +  AwsMailMessageIdSaveHook, +} 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.outboundDeliverer.preHooks.push( +      new AwsMailMessageIdRewriteHook(this.db), +    ); +    this.outboundDeliverer.postHooks.push( +      new AwsMailMessageIdSaveHook(this.db), +    ); + +    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!" }); +      }, +    ); +  } + +  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(); +  await app.setup(); +  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/deno/mail-relay/aws/context.ts b/deno/mail-relay/aws/context.ts new file mode 100644 index 0000000..b1e0336 --- /dev/null +++ b/deno/mail-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/deno/mail-relay/aws/deliver.ts b/deno/mail-relay/aws/deliver.ts new file mode 100644 index 0000000..0db5fa8 --- /dev/null +++ b/deno/mail-relay/aws/deliver.ts @@ -0,0 +1,114 @@ +// spellchecker: words sesv2 amazonses + +import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2"; + +import log from "../log.ts"; +import { DbService } from "../db.ts"; +import { +  Mail, +  MailDeliverContext, +  MailDeliverHook, +  SyncMailDeliverer, +} from "../mail.ts"; +import { AwsContext } from "./context.ts"; + +declare module "../mail.ts" { +  interface MailDeliverResult { +    awsMessageId?: string; +  } +} + +export class AwsMailMessageIdRewriteHook implements MailDeliverHook { +  readonly #db; + +  constructor(db: DbService) { +    this.#db = db; +  } + +  async callback(context: MailDeliverContext): Promise<void> { +    log.info("Rewrite message ids..."); +    const addresses = context.mail.simpleFindAllAddresses(); +    log.info(`Addresses found in mail: ${addresses.join(", ")}.`); +    for (const address of addresses) { +      const awsMessageId = await this.#db.messageIdToAws(address); +      if (awsMessageId != null && awsMessageId.length !== 0) { +        log.info(`Rewrite ${address} to ${awsMessageId}.`); +        context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId); +      } +    } +    log.info("Done rewrite message ids."); +  } +} + +export class AwsMailMessageIdSaveHook implements MailDeliverHook { +  readonly #db; + +  constructor(db: DbService) { +    this.#db = db; +  } + +  async callback(context: MailDeliverContext): Promise<void> { +    log.info("Save aws message ids..."); +    const messageId = context.mail.startSimpleParse().sections().headers() +      .messageId(); +    if (messageId == null) { +      log.info("Original mail does not have message id. Skip saving."); +      return; +    } +    if (context.result.awsMessageId != null) { +      log.info(`Saving ${messageId} => ${context.result.awsMessageId}.`); +      await this.#db.addMessageIdMap({ +        message_id: messageId, +        aws_message_id: context.result.awsMessageId, +      }); +    } +    log.info("Done save message ids."); +  } +} + +export class AwsMailDeliverer extends SyncMailDeliverer { +  readonly name = "aws"; +  readonly #aws; +  readonly #ses; + +  constructor(aws: AwsContext) { +    super(); +    this.#aws = aws; +    this.#ses = new SESv2Client(aws); +  } + +  protected override async doDeliver( +    mail: Mail, +    context: MailDeliverContext, +  ): Promise<void> { +    log.info("Begin to call aws send-email api..."); + +    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."); +      } else { +        context.result.awsMessageId = +          `${res.MessageId}@${this.#aws.region}.amazonses.com`; +      } + +      context.result.recipients.set("*", { +        kind: "done", +        message: +          `Successfully called aws send-email, message id ${context.result.awsMessageId}.`, +      }); +    } catch (cause) { +      context.result.recipients.set("*", { +        kind: "fail", +        message: "An error was thrown when calling aws send-email." + cause, +        cause, +      }); +    } +  } +} diff --git a/deno/mail-relay/aws/retriever.ts b/deno/mail-relay/aws/retriever.ts new file mode 100644 index 0000000..756cfc3 --- /dev/null +++ b/deno/mail-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.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); +    } +  } +}  | 
