aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuqian Yang <crupest@crupest.life>2025-04-10 15:12:46 +0800
committerYuqian Yang <crupest@crupest.life>2025-05-31 03:11:53 +0800
commit8cad2ca7812d654016dd7af2ee1695e81778b7db (patch)
tree9f996532b4bf3b0ebd5ace82f540565bc9f4e8d8
parentcbeeaf281f400230e4161f08cfef2fe4715f3773 (diff)
downloadcrupest-8cad2ca7812d654016dd7af2ee1695e81778b7db.tar.gz
crupest-8cad2ca7812d654016dd7af2ee1695e81778b7db.tar.bz2
crupest-8cad2ca7812d654016dd7af2ee1695e81778b7db.zip
HALF WORK!
-rw-r--r--.editorconfig2
-rw-r--r--services/config.template6
-rwxr-xr-xservices/docker/auto-backup/daemon.bash4
-rw-r--r--services/docker/mail-server/Dockerfile10
-rw-r--r--services/docker/mail-server/dovecot.conf193
-rw-r--r--services/docker/mail-server/relay/.gitignore3
-rw-r--r--services/docker/mail-server/relay/app.ts63
-rw-r--r--services/docker/mail-server/relay/aws/app.ts62
-rw-r--r--services/docker/mail-server/relay/aws/context.ts41
-rw-r--r--services/docker/mail-server/relay/aws/deliver.ts50
-rw-r--r--services/docker/mail-server/relay/aws/mail.ts7
-rw-r--r--services/docker/mail-server/relay/aws/retriever.ts102
-rw-r--r--services/docker/mail-server/relay/config.ts78
-rw-r--r--services/docker/mail-server/relay/cron.ts43
-rw-r--r--services/docker/mail-server/relay/db.test.ts24
-rw-r--r--services/docker/mail-server/relay/db.ts179
-rw-r--r--services/docker/mail-server/relay/deno.json24
-rw-r--r--services/docker/mail-server/relay/deno.lock1498
-rw-r--r--services/docker/mail-server/relay/dovecot.ts83
-rw-r--r--services/docker/mail-server/relay/logger.ts56
-rw-r--r--services/docker/mail-server/relay/mail.test.ts115
-rw-r--r--services/docker/mail-server/relay/mail.ts240
-rw-r--r--services/docker/mail-server/relay/main.ts20
-rw-r--r--services/docker/mail-server/relay/util.ts60
-rw-r--r--services/docker/nginx/configs/templates/mail.conf.template5
-rw-r--r--services/templates/docker-compose.yaml.template39
-rw-r--r--services/templates/envs/mail-server.env.template4
-rw-r--r--services/templates/envs/mailserver.env.template661
-rw-r--r--store/home/config/mihomo/config.yaml4
29 files changed, 2981 insertions, 695 deletions
diff --git a/.editorconfig b/.editorconfig
index 4ff2eca..ea11c46 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -24,7 +24,7 @@ indent_size = 2
[*.{c,cpp,h}]
indent_size = 2
-[*.{json,yaml,yml}]
+[*.{json,yaml,yml,yaml.template}]
indent_size = 2
[*.py]
diff --git a/services/config.template b/services/config.template
index 7b3d1dc..89448ce 100644
--- a/services/config.template
+++ b/services/config.template
@@ -1,11 +1,13 @@
-CRUPEST_MAIL_SERVER_DOMAIN=mail.@@CRUPEST_DOMAIN@@
CRUPEST_ROOT_URL=https://@@CRUPEST_DOMAIN@@
+CRUPEST_MAIL_SERVER_DOMAIN=mail.@@CRUPEST_DOMAIN@@
CRUPEST_DOCKER_DIR=@@CRUPEST_SERVICES_DIR@@/docker
CRUPEST_SERVICES_STATE_DIR=@@CRUPEST_SERVICES_DIR@@/state
CRUPEST_DATA_SECRET_DIR=@@CRUPEST_DATA_DIR@@/secret
CRUPEST_DATA_CERTBOT_DIR=@@CRUPEST_DATA_DIR@@/certbot
CRUPEST_DATA_GIT_DIR=@@CRUPEST_DATA_DIR@@/git
-CRUPEST_DATA_MAILSERVER_DIR=@@CRUPEST_DATA_DIR@@/dms
+CRUPEST_DATA_MAIL_SERVER_DIR=@@CRUPEST_DATA_DIR@@/mail-server
CRUPEST_DATA_ROUNDCUBE_DIR=@@CRUPEST_DATA_DIR@@/roundcube
CRUPEST_GENERATED_DIR=@@CRUPEST_SERVICES_DIR@@/generated
CRUPEST_GENERATED_NGINX_DIR=@@CRUPEST_GENERATED_DIR@@/nginx
+CRUPEST_SSL_FULLCHAIN_FILE=@@CRUPEST_DATA_CERTBOT_DIR@@/certs/live/@@CRUPEST_DOMAIN@@/fullchain.pem
+CRUPEST_SSL_PRIVATE_KEY_FILE=@@CRUPEST_DATA_CERTBOT_DIR@@/certs/live/@@CRUPEST_DOMAIN@@/privkey.pem
diff --git a/services/docker/auto-backup/daemon.bash b/services/docker/auto-backup/daemon.bash
index da9f853..c82e2d0 100755
--- a/services/docker/auto-backup/daemon.bash
+++ b/services/docker/auto-backup/daemon.bash
@@ -15,9 +15,7 @@ success() {
echo -e "\033[32mSuccess: " "$@" "\033[0m"
}
-if [[ -z "$CRUPEST_AUTO_BACKUP_INTERVAL" ]]; then
- die "Backup interval not set, please set it!"
-fi
+[[ -n "$CRUPEST_AUTO_BACKUP_INTERVAL" ]] || die "Backup interval not set, please set it!"
note "Checking secrets..."
[[ -n "$RCLONE_S3_PROVIDER" ]] || die "S3 provider not set!"
diff --git a/services/docker/mail-server/Dockerfile b/services/docker/mail-server/Dockerfile
new file mode 100644
index 0000000..9cd64c5
--- /dev/null
+++ b/services/docker/mail-server/Dockerfile
@@ -0,0 +1,10 @@
+FROM denoland/deno AS deno-build
+ADD relay /app/
+WORKDIR /app
+RUN deno install
+RUN deno task compile
+
+FROM dovecot/dovecot:latest-root
+ENV CRUPEST_MAILSERVER_DATA_PATH="/data/crupest-relay/"
+ADD dovecot.conf /etc/dovecot/dovecot.conf
+COPY --from=deno-build /app/out/crupest-relay /app/
diff --git a/services/docker/mail-server/dovecot.conf b/services/docker/mail-server/dovecot.conf
new file mode 100644
index 0000000..31c69cd
--- /dev/null
+++ b/services/docker/mail-server/dovecot.conf
@@ -0,0 +1,193 @@
+dovecot_config_version = 2.4.1
+dovecot_storage_version = 2.4.0
+
+base_dir = /run/dovecot
+state_dir = /run/dovecot
+log_path = /dev/stdout
+
+protocols = imap submission lmtp sieve
+
+mail_driver = maildir
+mail_home = /data/vmail/%{user | domain}/%{user | username}
+mail_path = ~/mail
+mail_log_events = delete undelete expunge save copy mailbox_create mailbox_delete mailbox_rename flag_change
+
+# Setup default mailboxes for inbox namespace
+@mailbox_defaults = english
+
+namespace inbox {
+ mailbox Archive {
+ special_use = "\\Archive"
+ }
+}
+
+mail_plugins {
+ fts = yes
+ fts_flatcurve = yes
+ mail_log = yes
+ notify = yes
+}
+
+fts_autoindex = yes
+fts_autoindex_max_recent_msgs = 999
+fts_search_add_missing = yes
+language_filters = normalizer-icu snowball stopwords
+
+language_tokenizers = generic email-address
+language_tokenizer_generic_algorithm = simple
+
+language en {
+ default = yes
+ filters = lowercase snowball english-possessive stopwords
+}
+
+fts flatcurve {
+ substring_search = yes
+}
+
+auth_mechanisms = plain login
+
+passdb passwd-file {
+ passwd_file_path = /data/userdb
+ default_password_scheme = SHA512-CRYPT
+}
+
+userdb passwd-file {
+ passwd_file_path = /data/userdb
+ fields {
+ uid:default = vmail
+ gid:default = vmail
+ home:default = /data/vmail/%{user | domain}/%{user | username}
+ }
+}
+
+ssl = yes
+ssl_server {
+ cert_file = /etc/dovecot/ssl/tls.crt
+ key_file = /etc/dovecot/ssl/tls.key
+}
+
+protocol imap {
+ mail_plugins {
+ imap_sieve = yes
+ imap_filter_sieve = yes
+ }
+}
+
+protocol lmtp {
+ mail_plugins {
+ sieve = yes
+ }
+}
+
+protocol lda {
+ mail_plugins {
+ sieve = yes
+ }
+}
+
+service imap-login {
+ process_min_avail = 1
+ client_limit = 100
+}
+
+service pop3-login {
+ process_min_avail = 1
+ client_limit = 100
+}
+
+service submission-login {
+ process_min_avail = 1
+ client_limit = 100
+
+ inet_listener submissions {
+ port = 465
+ ssl = yes
+ }
+}
+
+service managesieve-login {
+ process_min_avail = 1
+ client_limit = 100
+}
+
+sieve_plugins = sieve_imapsieve sieve_extprograms
+
+event_exporter log {
+ format = json
+ time_format = rfc3339
+}
+
+# Add default backend metrics
+@metric_defaults = backend
+
+# Log auth failures
+metric auth_failures {
+ filter = event=auth_request_finished AND NOT success=yes
+ exporter = log
+}
+
+metric imap_command {
+ filter = event=imap_command_finished
+ group_by cmd_name {
+ method discrete {
+ }
+ }
+ group_by tagged_reply_state {
+ method discrete {
+ }
+ }
+}
+
+metric smtp_command {
+ filter = event=smtp_server_command_finished and protocol=submission
+ group_by cmd_name {
+ method discrete {
+ }
+ }
+ group_by status_code {
+ method discrete {
+ }
+ }
+ group_by duration {
+ method exponential {
+ base = 10
+ min_magnitude = 1
+ max_magnitude = 5
+ }
+ }
+}
+
+metric lmtp_command {
+ filter = event=smtp_server_command_finished and protocol=lmtp
+ group_by cmd_name {
+ method discrete {
+ }
+ }
+ group_by status_code {
+ method discrete {
+ }
+ }
+ group_by duration {
+ method exponential {
+ base = 10
+ min_magnitude = 1
+ max_magnitude = 5
+ }
+ }
+}
+
+# Add duration metrics for deliveries
+metric mail_deliveries {
+ filter = event=mail_delivery_finished
+ group_by duration {
+ method exponential {
+ base = 10
+ min_magnitude = 1
+ max_magnitude = 5
+ }
+ }
+}
+
+!include_try vendor.d/*.conf
+!include_try conf.d/*.conf
diff --git a/services/docker/mail-server/relay/.gitignore b/services/docker/mail-server/relay/.gitignore
new file mode 100644
index 0000000..327aef0
--- /dev/null
+++ b/services/docker/mail-server/relay/.gitignore
@@ -0,0 +1,3 @@
+out
+.env.local
+db.sqlite
diff --git a/services/docker/mail-server/relay/app.ts b/services/docker/mail-server/relay/app.ts
new file mode 100644
index 0000000..e5417d6
--- /dev/null
+++ b/services/docker/mail-server/relay/app.ts
@@ -0,0 +1,63 @@
+import { Hono } from "hono";
+
+import { Logger, setLogger } from "./logger.ts";
+import { Config, setConfig } from "./config.ts";
+import { DbService } from "./db.ts";
+import { MailDeliverer } from "./mail.ts";
+import { DovecotMailDeliverer } from "./dovecot.ts";
+import { CronTask, CronTaskConfig } from "./cron.ts";
+
+export abstract class AppBase {
+ protected readonly logger;
+ protected readonly config;
+ protected readonly db: DbService;
+ protected readonly inboundDeliverer: MailDeliverer;
+ protected readonly crons: CronTask[] = [];
+ protected readonly routes: Hono[] = [];
+ readonly cliCommands: Record<string, () => Promise<void>> = {
+ "serve": () => this.serve(),
+ };
+
+ protected abstract readonly outboundDeliverer: MailDeliverer;
+
+ constructor() {
+ this.config = new Config();
+ setConfig(this.config);
+
+ const dataPath = this.config.getValue("dataPath");
+ Deno.mkdirSync(dataPath, { recursive: true });
+
+ this.logger = new Logger(`${dataPath}/log`);
+ setLogger(this.logger);
+
+ this.logger.log(this.config);
+
+ this.db = new DbService(`${dataPath}/db.sqlite`);
+ this.inboundDeliverer = new DovecotMailDeliverer();
+
+ const hono = new Hono()
+ .post("/send/raw", async (context) => {
+ this.outboundDeliverer.deliverRaw(await context.req.text());
+ })
+ .post("/receive/raw", async (context) => {
+ this.inboundDeliverer.deliverRaw(await context.req.text());
+ });
+ this.routes.push(hono);
+ }
+
+ createCron(config: CronTaskConfig): CronTask {
+ const cron = new CronTask(config);
+ this.crons.push(cron);
+ return cron;
+ }
+
+ async serve(): Promise<void> {
+ const hono = new Hono();
+ this.routes.forEach((h) => hono.route("/", h));
+
+ const server = Deno.serve({
+ hostname: "0.0.0.0",
+ }, hono.fetch);
+ await server.finished;
+ }
+}
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..d656b3f
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/app.ts
@@ -0,0 +1,62 @@
+import { z } from "zod";
+import { Hono } from "hono";
+import { zValidator } from "@hono/zod-validator";
+
+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);
+
+ 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.cliCommands["list-lives"] = async () => {
+ const liveMails = await this.#retriever.listLiveMails();
+ console.log(`Total ${liveMails.length}:`);
+ console.log(liveMails.join("\n"));
+ };
+
+ this.cliCommands["recycle-lives"] = async () => {
+ await this.#retriever.recycleLiveMails();
+ };
+ }
+
+ override serve(): Promise<void> {
+ this.createCron({
+ name: "live-mail-recycler",
+ interval: Temporal.Duration.from({
+ hours: 6,
+ }),
+ callback: () => {
+ return this.#retriever.recycleLiveMails();
+ },
+ startNow: true,
+ });
+
+ return super.serve();
+ }
+}
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..0dfafe9
--- /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: `${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..9bb8156
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/deliver.ts
@@ -0,0 +1,50 @@
+import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2";
+
+import { AwsContext } from "./context.ts";
+import {
+ Mail,
+ MailDeliverContext,
+ MailDeliverer,
+ MailDeliverReceiptResult,
+} from "./mail.ts";
+import { getLogger } from "../logger.ts";
+
+export class AwsMailDeliverer extends MailDeliverer {
+ readonly name = "aws";
+ readonly #ses;
+
+ constructor(aws: AwsContext) {
+ super();
+ const { region, credentials } = aws;
+ this.#ses = new SESv2Client({ region, credentials });
+ }
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ const result: MailDeliverReceiptResult = {
+ 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) {
+ getLogger().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.";
+ 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..4ad5c5e
--- /dev/null
+++ b/services/docker/mail-server/relay/aws/retriever.ts
@@ -0,0 +1,102 @@
+/// <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, MailDeliverer } from "../mail.ts";
+import { generateTimeStringForFileName } from "../util.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 #s3;
+
+ constructor(
+ aws: AwsContext,
+ public readonly inboundDeliverer: MailDeliverer,
+ ) {
+ const { region, credentials } = aws;
+ this.#s3 = new S3Client({ region, credentials });
+ }
+
+ async listLiveMails(): Promise<string[]> {
+ const listCommand = new ListObjectsV2Command({
+ Bucket: this.mailBucket,
+ Prefix: this.liveMailPrefix,
+ });
+ const res = await this.#s3.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) {
+ getLogger().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) {
+ getLogger().log(`Begin to deliver s3 mail ${s3Key}...`);
+
+ getLogger().log(`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();
+ getLogger().log(`Done fetching s3 mail ${s3Key}.`);
+
+ getLogger().log(`Delivering s3 mail ${s3Key}...`);
+ const mail = new Mail(rawMail);
+ await this.inboundDeliverer.deliver(mail);
+ getLogger().log(`Done delivering s3 mail ${s3Key}.`);
+
+ const date = mail.date ?? mail.simpleParseDate();
+ const dateString = date != null
+ ? generateTimeStringForFileName(date, true)
+ : "invalid-date";
+ const newPath = `${this.archiveMailPrefix}${dateString}/${s3Key}`;
+
+ getLogger().log(`Archiving s3 mail ${s3Key} to ${newPath}...`);
+ await s3MoveObject(this.#s3, this.mailBucket, mailPath, newPath);
+ getLogger().log(`Done delivering s3 mail ${s3Key}...`);
+ }
+
+ async recycleLiveMails() {
+ getLogger().log("Begin to recycle live mails...");
+ const mails = await this.listLiveMails();
+ getLogger().log(`Found ${mails.length} live mails`);
+ for (const s3Key of mails) {
+ await this.deliverS3Mail(s3Key);
+ }
+ }
+}
diff --git a/services/docker/mail-server/relay/config.ts b/services/docker/mail-server/relay/config.ts
new file mode 100644
index 0000000..77c6e6e
--- /dev/null
+++ b/services/docker/mail-server/relay/config.ts
@@ -0,0 +1,78 @@
+import { createSingleton, transformProperties } from "./util.ts";
+
+export const APP_PREFIX = "crupest";
+export const APP_NAME = "mailserver";
+
+interface ConfigItemDef {
+ env: string;
+ description: string;
+ default?: string;
+ secret?: boolean;
+}
+
+export const CONFIG_DEFS = {
+ mailDomain: {
+ env: "MAIL_DOMAIN",
+ description: "the part after `@` of an address",
+ },
+ dataPath: {
+ env: "DATA_PATH",
+ description: "path to save app persistent data",
+ },
+ awsAccessKeyId: { env: "AWS_USER", description: "aws access key id" },
+ awsSecretAccessKey: {
+ env: "AWS_PASSWORD",
+ description: "aws secret access key",
+ secret: true,
+ },
+ awsMailBucket: {
+ env: "AWS_MAIL_BUCKET",
+ description: "aws s3 bucket saving raw mails",
+ secret: true,
+ },
+} as const satisfies Record<string, ConfigItemDef>;
+
+type ConfigDefs = typeof CONFIG_DEFS;
+type ConfigKey = keyof ConfigDefs;
+type ConfigMap = {
+ [K in ConfigKey]: ConfigItemDef & { value: string };
+};
+
+function getFullEnvKey(key: string): string {
+ return `${APP_PREFIX.toUpperCase()}_${APP_NAME.toUpperCase()}_${key}`;
+}
+
+function resolveAppConfigItem(def: ConfigItemDef): string {
+ const envKey = getFullEnvKey(def.env);
+ const value = Deno.env.get(envKey);
+ if (value != null) return value;
+ if (def.default != null) return def.default;
+ throw new Error(
+ `Required env ${envKey} (${def.description}) is not set.`,
+ );
+}
+
+export class Config {
+ #config = transformProperties(
+ CONFIG_DEFS,
+ (def) => ({ ...def, value: resolveAppConfigItem(def) }),
+ ) as ConfigMap;
+
+ get<K extends ConfigKey>(key: K): ConfigMap[K] {
+ return this.#config[key];
+ }
+
+ getValue(key: ConfigKey): string {
+ return this.get(key).value;
+ }
+
+ [Symbol.for("Deno.customInspect")]() {
+ return Object.entries(this.#config).map(([key, item]) =>
+ `${key} [env: ${getFullEnvKey(item.env)}]: ${
+ item.secret === true ? "***" : item.value
+ }`
+ ).join("\n");
+ }
+}
+
+export const [getConfig, setConfig] = createSingleton<Config>("Config");
diff --git a/services/docker/mail-server/relay/cron.ts b/services/docker/mail-server/relay/cron.ts
new file mode 100644
index 0000000..b830a23
--- /dev/null
+++ b/services/docker/mail-server/relay/cron.ts
@@ -0,0 +1,43 @@
+export type CronCallback = (task: CronTask) => Promise<void>;
+
+export interface CronTaskConfig {
+ readonly name: string;
+ readonly interval: Temporal.Duration;
+ readonly callback: CronCallback;
+ readonly startNow?: boolean;
+}
+
+export class CronTask {
+ #timerTag: number | null = null;
+
+ constructor(public readonly config: CronTaskConfig) {
+ if (config.interval.sign !== 1) {
+ throw new Error("Cron task interval must be positive.");
+ }
+
+ if (config.startNow === true) {
+ this.start();
+ }
+ }
+
+ get running(): boolean {
+ return this.#timerTag != null;
+ }
+
+ start() {
+ if (this.#timerTag == null) {
+ this.#timerTag = setInterval(
+ this.config.callback,
+ this.config.interval.milliseconds,
+ this,
+ );
+ }
+ }
+
+ stop() {
+ if (this.#timerTag != null) {
+ clearInterval(this.#timerTag);
+ this.#timerTag = null;
+ }
+ }
+}
diff --git a/services/docker/mail-server/relay/db.test.ts b/services/docker/mail-server/relay/db.test.ts
new file mode 100644
index 0000000..eef3e1b
--- /dev/null
+++ b/services/docker/mail-server/relay/db.test.ts
@@ -0,0 +1,24 @@
+import { describe, it } from "@std/testing/bdd";
+import { expect } from "@std/expect/expect";
+
+import { DbService } from "./db.ts";
+
+describe("DbService", () => {
+ const mockRow = {
+ message_id: "mock-message-id@mock.mock",
+ aws_message_id: "mock-aws-message-id@mock.mock",
+ raw: "a mock raw mail content",
+ };
+
+ it("works", async () => {
+ const db = new DbService(":memory:");
+ await db.migrate();
+ await db.addMail(mockRow);
+ expect(await db.messageIdToAws(mockRow.message_id)).toBe(
+ mockRow.aws_message_id,
+ );
+ expect(await db.messageIdFromAws(mockRow.aws_message_id)).toBe(
+ mockRow.message_id,
+ );
+ });
+});
diff --git a/services/docker/mail-server/relay/db.ts b/services/docker/mail-server/relay/db.ts
new file mode 100644
index 0000000..ee8bad1
--- /dev/null
+++ b/services/docker/mail-server/relay/db.ts
@@ -0,0 +1,179 @@
+// spellchecker: words kysely insertable updateable introspector
+
+import {
+ Generated,
+ Insertable,
+ Kysely,
+ Migration,
+ Migrator,
+ SqliteDatabase,
+ SqliteDialect,
+ SqliteStatement,
+} from "kysely";
+import * as sqlite from "@db/sqlite";
+
+class SqliteStatementAdapter implements SqliteStatement {
+ constructor(public readonly stmt: sqlite.Statement) {}
+
+ get reader(): boolean {
+ return this.stmt.columnNames().length >= 1;
+ }
+
+ all(parameters: readonly unknown[]): unknown[] {
+ return this.stmt.all(...parameters as sqlite.BindValue[]);
+ }
+
+ iterate(parameters: readonly unknown[]): IterableIterator<unknown> {
+ return this.stmt.iter(...parameters as sqlite.BindValue[]);
+ }
+
+ run(
+ parameters: readonly unknown[],
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
+ const { db } = this.stmt;
+ const totalChangesBefore = db.totalChanges;
+ const changes = this.stmt.run(...parameters as sqlite.BindValue[]);
+ return {
+ changes: totalChangesBefore === db.totalChanges ? 0 : changes,
+ lastInsertRowid: db.lastInsertRowId,
+ };
+ }
+}
+
+class SqliteDatabaseAdapter implements SqliteDatabase {
+ constructor(public readonly db: sqlite.Database) {}
+
+ prepare(sql: string): SqliteStatementAdapter {
+ return new SqliteStatementAdapter(this.db.prepare(sql));
+ }
+
+ close(): void {
+ this.db.close();
+ }
+}
+
+export class DbError extends Error {
+}
+
+const NAMES = {
+ mail: {
+ table: "mail",
+ columns: {
+ id: "id",
+ messageId: "message_id",
+ awsMessageId: "aws_message_id",
+ date: "date",
+ raw: "raw",
+ },
+ },
+} as const;
+
+interface MailTable {
+ [NAMES.mail.columns.id]: Generated<number>;
+ [NAMES.mail.columns.messageId]: string;
+ [NAMES.mail.columns.awsMessageId]: string | null;
+ [NAMES.mail.columns.date]: string | null;
+ [NAMES.mail.columns.raw]: string;
+}
+
+interface Database {
+ [NAMES.mail.table]: MailTable;
+}
+
+const migrations: Record<string, Migration> = {
+ "0001-init": {
+ // deno-lint-ignore no-explicit-any
+ async up(db: Kysely<any>): Promise<void> {
+ await db.schema
+ .createTable(NAMES.mail.table)
+ .addColumn(
+ NAMES.mail.columns.id,
+ "integer",
+ (col) => col.primaryKey().autoIncrement(),
+ )
+ .addColumn(
+ NAMES.mail.columns.messageId,
+ "text",
+ (col) => col.notNull().unique(),
+ )
+ .addColumn(
+ NAMES.mail.columns.awsMessageId,
+ "text",
+ (col) => col.unique(),
+ )
+ .addColumn(NAMES.mail.columns.date, "text")
+ .addColumn(NAMES.mail.columns.raw, "text", (col) => col.notNull())
+ .execute();
+
+ for (
+ const column of [
+ NAMES.mail.columns.messageId,
+ NAMES.mail.columns.awsMessageId,
+ ]
+ ) {
+ await db.schema
+ .createIndex(`${NAMES.mail.table}_${column}`)
+ .on(NAMES.mail.table)
+ .column(column)
+ .execute();
+ }
+ },
+
+ // deno-lint-ignore no-explicit-any
+ async down(db: Kysely<any>): Promise<void> {
+ await db.schema.dropTable(NAMES.mail.table).execute();
+ },
+ },
+};
+
+export class DbService {
+ #db;
+ #kysely;
+ #migrator;
+
+ constructor(public readonly path: string) {
+ this.#db = new sqlite.Database(path);
+ this.#kysely = new Kysely<Database>({
+ dialect: new SqliteDialect(
+ { database: new SqliteDatabaseAdapter(this.#db) },
+ ),
+ });
+ this.#migrator = new Migrator({
+ db: this.#kysely,
+ provider: {
+ getMigrations(): Promise<Record<string, Migration>> {
+ return Promise.resolve(migrations);
+ },
+ },
+ });
+ }
+
+ async migrate(): Promise<void> {
+ await this.#migrator.migrateToLatest();
+ }
+
+ async addMail(mail: Insertable<MailTable>): Promise<number> {
+ const inserted = await this.#kysely.insertInto(NAMES.mail.table).values(
+ mail,
+ ).executeTakeFirstOrThrow();
+ return Number(inserted.insertId!);
+ }
+
+ async messageIdToAws(messageId: string): Promise<string | null> {
+ const row = await this.#kysely.selectFrom(NAMES.mail.table).where(
+ NAMES.mail.columns.messageId,
+ "=",
+ messageId,
+ ).select(NAMES.mail.columns.awsMessageId).executeTakeFirst();
+ return row?.aws_message_id ?? null;
+ }
+
+ async messageIdFromAws(awsMessageId: string): Promise<string | null> {
+ const row = await this.#kysely.selectFrom(NAMES.mail.table).where(
+ NAMES.mail.columns.awsMessageId,
+ "=",
+ awsMessageId,
+ ).select(NAMES.mail.columns.messageId).executeTakeFirst();
+ return row?.message_id ?? null;
+ }
+}
diff --git a/services/docker/mail-server/relay/deno.json b/services/docker/mail-server/relay/deno.json
new file mode 100644
index 0000000..2d05336
--- /dev/null
+++ b/services/docker/mail-server/relay/deno.json
@@ -0,0 +1,24 @@
+{
+ "unstable": [ "temporal", "cron" ],
+ "tasks": {
+ "run": "deno run -A main.ts",
+ "list-lives": "deno task run list-lives",
+ "test": "deno test -A",
+ "compile": "deno compile -o out/crupest-relay -A main.ts"
+ },
+ "imports": {
+ "@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.797.0",
+ "@aws-sdk/client-sesv2": "npm:@aws-sdk/client-sesv2@^3.782.0",
+ "@db/sqlite": "jsr:@db/sqlite@^0.12.0",
+ "@hono/zod-validator": "npm:@hono/zod-validator@^0.5.0",
+ "@std/cli": "jsr:@std/cli@^1.0.17",
+ "@std/encoding": "jsr:@std/encoding@^1.0.10",
+ "@std/expect": "jsr:@std/expect@^1.0.16",
+ "@std/path": "jsr:@std/path@^1.0.9",
+ "@std/testing": "jsr:@std/testing@^1.0.12",
+ "email-addresses": "npm:email-addresses@^5.0.0",
+ "hono": "npm:hono@^4.7.10",
+ "kysely": "npm:kysely@^0.28.2",
+ "zod": "npm:zod@^3.25.20"
+ }
+}
diff --git a/services/docker/mail-server/relay/deno.lock b/services/docker/mail-server/relay/deno.lock
new file mode 100644
index 0000000..d68dff7
--- /dev/null
+++ b/services/docker/mail-server/relay/deno.lock
@@ -0,0 +1,1498 @@
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@db/sqlite@0.12": "0.12.0",
+ "jsr:@denosaurs/plug@1": "1.1.0",
+ "jsr:@std/assert@0.217": "0.217.0",
+ "jsr:@std/assert@1": "1.0.13",
+ "jsr:@std/assert@^1.0.13": "1.0.13",
+ "jsr:@std/async@^1.0.13": "1.0.13",
+ "jsr:@std/cli@^1.0.17": "1.0.17",
+ "jsr:@std/data-structures@^1.0.8": "1.0.8",
+ "jsr:@std/encoding@1": "1.0.10",
+ "jsr:@std/encoding@^1.0.10": "1.0.10",
+ "jsr:@std/expect@^1.0.16": "1.0.16",
+ "jsr:@std/fmt@1": "1.0.8",
+ "jsr:@std/fs@1": "1.0.17",
+ "jsr:@std/fs@^1.0.17": "1.0.17",
+ "jsr:@std/internal@^1.0.6": "1.0.7",
+ "jsr:@std/internal@^1.0.7": "1.0.7",
+ "jsr:@std/path@0.217": "0.217.0",
+ "jsr:@std/path@1": "1.0.9",
+ "jsr:@std/path@^1.0.9": "1.0.9",
+ "jsr:@std/testing@^1.0.12": "1.0.12",
+ "npm:@aws-sdk/client-s3@^3.797.0": "3.797.0",
+ "npm:@aws-sdk/client-sesv2@^3.782.0": "3.782.0",
+ "npm:@hono/zod-validator@0.5": "0.5.0_hono@4.7.10_zod@3.25.20",
+ "npm:@types/node@*": "22.15.15",
+ "npm:email-addresses@5": "5.0.0",
+ "npm:hono@^4.7.10": "4.7.10",
+ "npm:kysely@~0.28.2": "0.28.2",
+ "npm:zod@^3.25.20": "3.25.20"
+ },
+ "jsr": {
+ "@db/sqlite@0.12.0": {
+ "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
+ "dependencies": [
+ "jsr:@denosaurs/plug",
+ "jsr:@std/path@0.217"
+ ]
+ },
+ "@denosaurs/plug@1.1.0": {
+ "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
+ "dependencies": [
+ "jsr:@std/encoding@1",
+ "jsr:@std/fmt",
+ "jsr:@std/fs@1",
+ "jsr:@std/path@1"
+ ]
+ },
+ "@std/assert@0.217.0": {
+ "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
+ },
+ "@std/assert@1.0.13": {
+ "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
+ "dependencies": [
+ "jsr:@std/internal@^1.0.6"
+ ]
+ },
+ "@std/async@1.0.13": {
+ "integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96"
+ },
+ "@std/cli@1.0.17": {
+ "integrity": "e15b9abe629e17be90cc6216327f03a29eae613365f1353837fa749aad29ce7b"
+ },
+ "@std/data-structures@1.0.8": {
+ "integrity": "2fb7219247e044c8fcd51341788547575653c82ae2c759ff209e0263ba7d9b66"
+ },
+ "@std/encoding@1.0.10": {
+ "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
+ },
+ "@std/expect@1.0.16": {
+ "integrity": "ceeef6dda21f256a5f0f083fcc0eaca175428b523359a9b1d9b3a1df11cc7391",
+ "dependencies": [
+ "jsr:@std/assert@^1.0.13",
+ "jsr:@std/internal@^1.0.7"
+ ]
+ },
+ "@std/fmt@1.0.8": {
+ "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
+ },
+ "@std/fs@1.0.17": {
+ "integrity": "1c00c632677c1158988ef7a004cb16137f870aafdb8163b9dce86ec652f3952b",
+ "dependencies": [
+ "jsr:@std/path@^1.0.9"
+ ]
+ },
+ "@std/internal@1.0.7": {
+ "integrity": "39eeb5265190a7bc5d5591c9ff019490bd1f2c3907c044a11b0d545796158a0f"
+ },
+ "@std/path@0.217.0": {
+ "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
+ "dependencies": [
+ "jsr:@std/assert@0.217"
+ ]
+ },
+ "@std/path@1.0.9": {
+ "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
+ },
+ "@std/testing@1.0.12": {
+ "integrity": "fec973a45ccc62c540fb89296199051fee142409138fd6e3eae409366bcd4720",
+ "dependencies": [
+ "jsr:@std/assert@^1.0.13",
+ "jsr:@std/async",
+ "jsr:@std/data-structures",
+ "jsr:@std/fs@^1.0.17",
+ "jsr:@std/internal@^1.0.7",
+ "jsr:@std/path@^1.0.9"
+ ]
+ }
+ },
+ "npm": {
+ "@aws-crypto/crc32@5.2.0": {
+ "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
+ "dependencies": [
+ "@aws-crypto/util",
+ "@aws-sdk/types",
+ "tslib"
+ ]
+ },
+ "@aws-crypto/crc32c@5.2.0": {
+ "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
+ "dependencies": [
+ "@aws-crypto/util",
+ "@aws-sdk/types",
+ "tslib"
+ ]
+ },
+ "@aws-crypto/sha1-browser@5.2.0": {
+ "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
+ "dependencies": [
+ "@aws-crypto/supports-web-crypto",
+ "@aws-crypto/util",
+ "@aws-sdk/types",
+ "@aws-sdk/util-locate-window",
+ "@smithy/util-utf8@2.3.0",
+ "tslib"
+ ]
+ },
+ "@aws-crypto/sha256-browser@5.2.0": {
+ "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
+ "dependencies": [
+ "@aws-crypto/sha256-js",
+ "@aws-crypto/supports-web-crypto",
+ "@aws-crypto/util",
+ "@aws-sdk/types",
+ "@aws-sdk/util-locate-window",
+ "@smithy/util-utf8@2.3.0",
+ "tslib"
+ ]
+ },
+ "@aws-crypto/sha256-js@5.2.0": {
+ "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
+ "dependencies": [
+ "@aws-crypto/util",
+ "@aws-sdk/types",
+ "tslib"
+ ]
+ },
+ "@aws-crypto/supports-web-crypto@5.2.0": {
+ "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@aws-crypto/util@5.2.0": {
+ "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/util-utf8@2.3.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/client-s3@3.797.0": {
+ "integrity": "sha512-N7pB94mXi4fCt+rYmR9TzfbbwZsWs6Mnk/jDNX9sAZyWkZQnS3AZ/nRtnUmdCimdnOPOMNVjmAoZ4mW3Ff8LDw==",
+ "dependencies": [
+ "@aws-crypto/sha1-browser",
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/credential-provider-node@3.797.0",
+ "@aws-sdk/middleware-bucket-endpoint",
+ "@aws-sdk/middleware-expect-continue",
+ "@aws-sdk/middleware-flexible-checksums",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-location-constraint",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-sdk-s3@3.796.0",
+ "@aws-sdk/middleware-ssec",
+ "@aws-sdk/middleware-user-agent@3.796.0",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/signature-v4-multi-region@3.796.0",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.787.0",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node@3.796.0",
+ "@aws-sdk/xml-builder",
+ "@smithy/config-resolver",
+ "@smithy/core",
+ "@smithy/eventstream-serde-browser",
+ "@smithy/eventstream-serde-config-resolver",
+ "@smithy/eventstream-serde-node",
+ "@smithy/fetch-http-handler",
+ "@smithy/hash-blob-browser",
+ "@smithy/hash-node",
+ "@smithy/hash-stream-node",
+ "@smithy/invalid-dependency",
+ "@smithy/md5-js",
+ "@smithy/middleware-content-length",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-retry",
+ "@smithy/middleware-serde",
+ "@smithy/middleware-stack",
+ "@smithy/node-config-provider",
+ "@smithy/node-http-handler",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-body-length-node",
+ "@smithy/util-defaults-mode-browser",
+ "@smithy/util-defaults-mode-node",
+ "@smithy/util-endpoints",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "@smithy/util-stream",
+ "@smithy/util-utf8@4.0.0",
+ "@smithy/util-waiter",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/client-sesv2@3.782.0": {
+ "integrity": "sha512-h7Uhh+9BZ9UCE+SM5vld5d10M3VpF9gYbqmzW4+gWtBq9lmsLWjB9Dsk74Xrh4i7s9op7TEfVYm/o+ZB180BtQ==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/credential-provider-node@3.782.0",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent@3.782.0",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/signature-v4-multi-region@3.775.0",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.782.0",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node@3.782.0",
+ "@smithy/config-resolver",
+ "@smithy/core",
+ "@smithy/fetch-http-handler",
+ "@smithy/hash-node",
+ "@smithy/invalid-dependency",
+ "@smithy/middleware-content-length",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-retry",
+ "@smithy/middleware-serde",
+ "@smithy/middleware-stack",
+ "@smithy/node-config-provider",
+ "@smithy/node-http-handler",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-body-length-node",
+ "@smithy/util-defaults-mode-browser",
+ "@smithy/util-defaults-mode-node",
+ "@smithy/util-endpoints",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/client-sso@3.782.0": {
+ "integrity": "sha512-5GlJBejo8wqMpSSEKb45WE82YxI2k73YuebjLH/eWDNQeE6VI5Bh9lA1YQ7xNkLLH8hIsb0pSfKVuwh0VEzVrg==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent@3.782.0",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.782.0",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node@3.782.0",
+ "@smithy/config-resolver",
+ "@smithy/core",
+ "@smithy/fetch-http-handler",
+ "@smithy/hash-node",
+ "@smithy/invalid-dependency",
+ "@smithy/middleware-content-length",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-retry",
+ "@smithy/middleware-serde",
+ "@smithy/middleware-stack",
+ "@smithy/node-config-provider",
+ "@smithy/node-http-handler",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-body-length-node",
+ "@smithy/util-defaults-mode-browser",
+ "@smithy/util-defaults-mode-node",
+ "@smithy/util-endpoints",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/client-sso@3.797.0": {
+ "integrity": "sha512-9xuR918p7tShR67ZL+AOSbydpJxSHAOdXcQswxxWR/hKCF7tULX7tyL3gNo3l/ETp0CDcStvorOdH/nCbzEOjw==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent@3.796.0",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.787.0",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node@3.796.0",
+ "@smithy/config-resolver",
+ "@smithy/core",
+ "@smithy/fetch-http-handler",
+ "@smithy/hash-node",
+ "@smithy/invalid-dependency",
+ "@smithy/middleware-content-length",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-retry",
+ "@smithy/middleware-serde",
+ "@smithy/middleware-stack",
+ "@smithy/node-config-provider",
+ "@smithy/node-http-handler",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-body-length-node",
+ "@smithy/util-defaults-mode-browser",
+ "@smithy/util-defaults-mode-node",
+ "@smithy/util-endpoints",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/core@3.775.0": {
+ "integrity": "sha512-8vpW4WihVfz0DX+7WnnLGm3GuQER++b0IwQG35JlQMlgqnc44M//KbJPsIHA0aJUJVwJAEShgfr5dUbY8WUzaA==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/core",
+ "@smithy/node-config-provider",
+ "@smithy/property-provider",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4@5.0.2",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-middleware",
+ "fast-xml-parser",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/core@3.796.0": {
+ "integrity": "sha512-tH8Sp7lCxISVoLnkyv4AouuXs2CDlMhTuesWa0lq2NX1f+DXsMwSBtN37ttZdpFMw3F8mWdsJt27X9h2Oq868A==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/core",
+ "@smithy/node-config-provider",
+ "@smithy/property-provider",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4@5.1.0",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-middleware",
+ "fast-xml-parser",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-env@3.775.0": {
+ "integrity": "sha512-6ESVxwCbGm7WZ17kY1fjmxQud43vzJFoLd4bmlR+idQSWdqlzGDYdcfzpjDKTcivdtNrVYmFvcH1JBUwCRAZhw==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-env@3.796.0": {
+ "integrity": "sha512-kQzGKm4IOYYO6vUrai2JocNwhJm4Aml2BsAV+tBhFhhkutE7khf9PUucoVjB78b0J48nF+kdSacqzY+gB81/Uw==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-http@3.775.0": {
+ "integrity": "sha512-PjDQeDH/J1S0yWV32wCj2k5liRo0ssXMseCBEkCsD3SqsU8o5cU82b0hMX4sAib/RkglCSZqGO0xMiN0/7ndww==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/types",
+ "@smithy/fetch-http-handler",
+ "@smithy/node-http-handler",
+ "@smithy/property-provider",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-stream",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-http@3.796.0": {
+ "integrity": "sha512-wWOT6VAHIKOuHdKFGm1iyKvx7f6+Kc/YTzFWJPuT+l+CPlXR6ylP1UMIDsHHLKpMzsrh3CH77QDsjkhQrnKkfg==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/types",
+ "@smithy/fetch-http-handler",
+ "@smithy/node-http-handler",
+ "@smithy/property-provider",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-stream",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-ini@3.782.0": {
+ "integrity": "sha512-wd4KdRy2YjLsE4Y7pz00470Iip06GlRHkG4dyLW7/hFMzEO2o7ixswCWp6J2VGZVAX64acknlv2Q0z02ebjmhw==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/credential-provider-env@3.775.0",
+ "@aws-sdk/credential-provider-http@3.775.0",
+ "@aws-sdk/credential-provider-process@3.775.0",
+ "@aws-sdk/credential-provider-sso@3.782.0",
+ "@aws-sdk/credential-provider-web-identity@3.782.0",
+ "@aws-sdk/nested-clients@3.782.0",
+ "@aws-sdk/types",
+ "@smithy/credential-provider-imds",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-ini@3.797.0": {
+ "integrity": "sha512-Zpj6pJ2hnebrhLDr+x61ArMUkjHG6mfJRfamHxeVTgZkhLcwHjC5aM4u9pWTVugIaPY+VBtgkKPbi3TRbHlt2g==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/credential-provider-env@3.796.0",
+ "@aws-sdk/credential-provider-http@3.796.0",
+ "@aws-sdk/credential-provider-process@3.796.0",
+ "@aws-sdk/credential-provider-sso@3.797.0",
+ "@aws-sdk/credential-provider-web-identity@3.797.0",
+ "@aws-sdk/nested-clients@3.797.0",
+ "@aws-sdk/types",
+ "@smithy/credential-provider-imds",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-node@3.782.0": {
+ "integrity": "sha512-HZiAF+TCEyKjju9dgysjiPIWgt/+VerGaeEp18mvKLNfgKz1d+/82A2USEpNKTze7v3cMFASx3CvL8yYyF7mJw==",
+ "dependencies": [
+ "@aws-sdk/credential-provider-env@3.775.0",
+ "@aws-sdk/credential-provider-http@3.775.0",
+ "@aws-sdk/credential-provider-ini@3.782.0",
+ "@aws-sdk/credential-provider-process@3.775.0",
+ "@aws-sdk/credential-provider-sso@3.782.0",
+ "@aws-sdk/credential-provider-web-identity@3.782.0",
+ "@aws-sdk/types",
+ "@smithy/credential-provider-imds",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-node@3.797.0": {
+ "integrity": "sha512-xJSWvvnmzEfHbqbpN4F3E3mI9+zJ/VWLGiKOjzX1Inbspa5WqNn2GoMamolZR2TvvZS4F3Hp73TD1WoBzkIjuw==",
+ "dependencies": [
+ "@aws-sdk/credential-provider-env@3.796.0",
+ "@aws-sdk/credential-provider-http@3.796.0",
+ "@aws-sdk/credential-provider-ini@3.797.0",
+ "@aws-sdk/credential-provider-process@3.796.0",
+ "@aws-sdk/credential-provider-sso@3.797.0",
+ "@aws-sdk/credential-provider-web-identity@3.797.0",
+ "@aws-sdk/types",
+ "@smithy/credential-provider-imds",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-process@3.775.0": {
+ "integrity": "sha512-A6k68H9rQp+2+7P7SGO90Csw6nrUEm0Qfjpn9Etc4EboZhhCLs9b66umUsTsSBHus4FDIe5JQxfCUyt1wgNogg==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-process@3.796.0": {
+ "integrity": "sha512-r4e8/4AdKn/qQbRVocW7oXkpoiuXdTv0qty8AASNLnbQnT1vjD1bvmP6kp4fbHPWgwY8I9h0Dqjp49uy9Bqyuw==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-sso@3.782.0": {
+ "integrity": "sha512-1y1ucxTtTIGDSNSNxriQY8msinilhe9gGvQpUDYW9gboyC7WQJPDw66imy258V6osdtdi+xoHzVCbCz3WhosMQ==",
+ "dependencies": [
+ "@aws-sdk/client-sso@3.782.0",
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/token-providers@3.782.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-sso@3.797.0": {
+ "integrity": "sha512-VlyWnjTsTnBXqXcEW0nw3S7nj00n9fYwF6uU6HPO9t860yIySG01lNPAWTvAt3DfVL5SRS0GANriCZF6ohcMcQ==",
+ "dependencies": [
+ "@aws-sdk/client-sso@3.797.0",
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/token-providers@3.797.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-web-identity@3.782.0": {
+ "integrity": "sha512-xCna0opVPaueEbJoclj5C6OpDNi0Gynj+4d7tnuXGgQhTHPyAz8ZyClkVqpi5qvHTgxROdUEDxWqEO5jqRHZHQ==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/nested-clients@3.782.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-web-identity@3.797.0": {
+ "integrity": "sha512-DIb05FEmdOX7bNsqSVEAB3UkaDgrYHonQ2+gcBLqZ7LoDNnovHIlvC5jii93usgEStxITZstnzw+49keNEgVWw==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/nested-clients@3.797.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-bucket-endpoint@3.775.0": {
+ "integrity": "sha512-qogMIpVChDYr4xiUNC19/RDSw/sKoHkAhouS6Skxiy6s27HBhow1L3Z1qVYXuBmOZGSWPU0xiyZCvOyWrv9s+Q==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@aws-sdk/util-arn-parser",
+ "@smithy/node-config-provider",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-config-provider",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-expect-continue@3.775.0": {
+ "integrity": "sha512-Apd3owkIeUW5dnk3au9np2IdW2N0zc9NjTjHiH+Mx3zqwSrc+m+ANgJVgk9mnQjMzU/vb7VuxJ0eqdEbp5gYsg==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-flexible-checksums@3.796.0": {
+ "integrity": "sha512-JTqnyzGlbvXDcEnBtd5LFNrCFKUHnGyp/V9+BkvzNP02WXABLWzYvj1TCaf5pQySwK/b4kVn5lvbpTi0rXqjZw==",
+ "dependencies": [
+ "@aws-crypto/crc32",
+ "@aws-crypto/crc32c",
+ "@aws-crypto/util",
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/types",
+ "@smithy/is-array-buffer@4.0.0",
+ "@smithy/node-config-provider",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-middleware",
+ "@smithy/util-stream",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-host-header@3.775.0": {
+ "integrity": "sha512-tkSegM0Z6WMXpLB8oPys/d+umYIocvO298mGvcMCncpRl77L9XkvSLJIFzaHes+o7djAgIduYw8wKIMStFss2w==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-location-constraint@3.775.0": {
+ "integrity": "sha512-8TMXEHZXZTFTckQLyBT5aEI8fX11HZcwZseRifvBKKpj0RZDk4F0EEYGxeNSPpUQ7n+PRWyfAEnnZNRdAj/1NQ==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-logger@3.775.0": {
+ "integrity": "sha512-FaxO1xom4MAoUJsldmR92nT1G6uZxTdNYOFYtdHfd6N2wcNaTuxgjIvqzg5y7QIH9kn58XX/dzf1iTjgqUStZw==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-recursion-detection@3.775.0": {
+ "integrity": "sha512-GLCzC8D0A0YDG5u3F5U03Vb9j5tcOEFhr8oc6PDk0k0vm5VwtZOE6LvK7hcCSoAB4HXyOUM0sQuXrbaAh9OwXA==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-sdk-s3@3.775.0": {
+ "integrity": "sha512-zsvcu7cWB28JJ60gVvjxPCI7ZU7jWGcpNACPiZGyVtjYXwcxyhXbYEVDSWKsSA6ERpz9XrpLYod8INQWfW3ECg==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/types",
+ "@aws-sdk/util-arn-parser",
+ "@smithy/core",
+ "@smithy/node-config-provider",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4@5.0.2",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-config-provider",
+ "@smithy/util-middleware",
+ "@smithy/util-stream",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-sdk-s3@3.796.0": {
+ "integrity": "sha512-5o78oE79sGOtYkL7Up02h2nmr9UhGQZJgxE29EBdTw4dZ1EaA46L+C8oA+fBCmAB5xPQsjQqvhRrsr4Lcp+jZQ==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/types",
+ "@aws-sdk/util-arn-parser",
+ "@smithy/core",
+ "@smithy/node-config-provider",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4@5.1.0",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-config-provider",
+ "@smithy/util-middleware",
+ "@smithy/util-stream",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-ssec@3.775.0": {
+ "integrity": "sha512-Iw1RHD8vfAWWPzBBIKaojO4GAvQkHOYIpKdAfis/EUSUmSa79QsnXnRqsdcE0mCB0Ylj23yi+ah4/0wh9FsekA==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-user-agent@3.782.0": {
+ "integrity": "sha512-i32H2R6IItX+bQ2p4+v2gGO2jA80jQoJO2m1xjU9rYWQW3+ErWy4I5YIuQHTBfb6hSdAHbaRfqPDgbv9J2rjEg==",
+ "dependencies": [
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.782.0",
+ "@smithy/core",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-user-agent@3.796.0": {
+ "integrity": "sha512-IeNg+3jNWT37J45opi5Jx89hGF0lOnZjiNwlMp3rKq7PlOqy8kWq5J1Gxk0W3tIkPpuf68CtBs/QFrRXWOjsZw==",
+ "dependencies": [
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.787.0",
+ "@smithy/core",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/nested-clients@3.782.0": {
+ "integrity": "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core@3.775.0",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent@3.782.0",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.782.0",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node@3.782.0",
+ "@smithy/config-resolver",
+ "@smithy/core",
+ "@smithy/fetch-http-handler",
+ "@smithy/hash-node",
+ "@smithy/invalid-dependency",
+ "@smithy/middleware-content-length",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-retry",
+ "@smithy/middleware-serde",
+ "@smithy/middleware-stack",
+ "@smithy/node-config-provider",
+ "@smithy/node-http-handler",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-body-length-node",
+ "@smithy/util-defaults-mode-browser",
+ "@smithy/util-defaults-mode-node",
+ "@smithy/util-endpoints",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/nested-clients@3.797.0": {
+ "integrity": "sha512-xCsRKdsv0GAg9E28fvYBdC3JR2xdtZ2o41MVknOs+pSFtMsZm3SsgxObN35p1OTMk/o/V0LORGVLnFQMlc5QiA==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core@3.796.0",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent@3.796.0",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints@3.787.0",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node@3.796.0",
+ "@smithy/config-resolver",
+ "@smithy/core",
+ "@smithy/fetch-http-handler",
+ "@smithy/hash-node",
+ "@smithy/invalid-dependency",
+ "@smithy/middleware-content-length",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-retry",
+ "@smithy/middleware-serde",
+ "@smithy/middleware-stack",
+ "@smithy/node-config-provider",
+ "@smithy/node-http-handler",
+ "@smithy/protocol-http",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-body-length-node",
+ "@smithy/util-defaults-mode-browser",
+ "@smithy/util-defaults-mode-node",
+ "@smithy/util-endpoints",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/region-config-resolver@3.775.0": {
+ "integrity": "sha512-40iH3LJjrQS3LKUJAl7Wj0bln7RFPEvUYKFxtP8a+oKFDO0F65F52xZxIJbPn6sHkxWDAnZlGgdjZXM3p2g5wQ==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "@smithy/util-config-provider",
+ "@smithy/util-middleware",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/signature-v4-multi-region@3.775.0": {
+ "integrity": "sha512-cnGk8GDfTMJ8p7+qSk92QlIk2bmTmFJqhYxcXZ9PysjZtx0xmfCMxnG3Hjy1oU2mt5boPCVSOptqtWixayM17g==",
+ "dependencies": [
+ "@aws-sdk/middleware-sdk-s3@3.775.0",
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4@5.0.2",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/signature-v4-multi-region@3.796.0": {
+ "integrity": "sha512-JAOLdvazTc9HlTFslSrIOrKRMuOruuM3FeGw0hyfLP/RIbjd9bqe/xLIzDSJr3wpCpJs0sXoofwJgXtgTipvjA==",
+ "dependencies": [
+ "@aws-sdk/middleware-sdk-s3@3.796.0",
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4@5.1.0",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/token-providers@3.782.0": {
+ "integrity": "sha512-4tPuk/3+THPrzKaXW4jE2R67UyGwHLFizZ47pcjJWbhb78IIJAy94vbeqEQ+veS84KF5TXcU7g5jGTXC0D70Wg==",
+ "dependencies": [
+ "@aws-sdk/nested-clients@3.782.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/token-providers@3.797.0": {
+ "integrity": "sha512-TLFkP4BBdkH2zCXhG3JjaYrRft25MMZ+6/YDz1C/ikq2Zk8krUbVoSmhtYMVz10JtxAPiQ++w0vI/qbz2JSDXg==",
+ "dependencies": [
+ "@aws-sdk/nested-clients@3.797.0",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/types@3.775.0": {
+ "integrity": "sha512-ZoGKwa4C9fC9Av6bdfqcW6Ix5ot05F/S4VxWR2nHuMv7hzfmAjTOcUiWT7UR4hM/U0whf84VhDtXN/DWAk52KA==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-arn-parser@3.723.0": {
+ "integrity": "sha512-ZhEfvUwNliOQROcAk34WJWVYTlTa4694kSVhDSjW6lE1bMataPnIN8A0ycukEzBXmd8ZSoBcQLn6lKGl7XIJ5w==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-endpoints@3.782.0": {
+ "integrity": "sha512-/RJOAO7o7HI6lEa4ASbFFLHGU9iPK876BhsVfnl54MvApPVYWQ9sHO0anOUim2S5lQTwd/6ghuH3rFYSq/+rdw==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "@smithy/util-endpoints",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-endpoints@3.787.0": {
+ "integrity": "sha512-fd3zkiOkwnbdbN0Xp9TsP5SWrmv0SpT70YEdbb8wAj2DWQwiCmFszaSs+YCvhoCdmlR3Wl9Spu0pGpSAGKeYvQ==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "@smithy/util-endpoints",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-locate-window@3.723.0": {
+ "integrity": "sha512-Yf2CS10BqK688DRsrKI/EO6B8ff5J86NXe4C+VCysK7UOgN0l1zOTeTukZ3H8Q9tYYX3oaF1961o8vRkFm7Nmw==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-user-agent-browser@3.775.0": {
+ "integrity": "sha512-txw2wkiJmZKVdDbscK7VBK+u+TJnRtlUjRTLei+elZg2ADhpQxfVAQl436FUeIv6AhB/oRHW6/K/EAGXUSWi0A==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "bowser",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-user-agent-node@3.782.0": {
+ "integrity": "sha512-dMFkUBgh2Bxuw8fYZQoH/u3H4afQ12VSkzEi//qFiDTwbKYq+u+RYjc8GLDM6JSK1BShMu5AVR7HD4ap1TYUnA==",
+ "dependencies": [
+ "@aws-sdk/middleware-user-agent@3.782.0",
+ "@aws-sdk/types",
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-user-agent-node@3.796.0": {
+ "integrity": "sha512-9fQpNcHgVFitf1tbTT8V1xGRoRHSmOAWjrhevo6Tc0WoINMAKz+4JNqfVGWRE5Tmtpq0oHKo1RmvxXQQtJYciA==",
+ "dependencies": [
+ "@aws-sdk/middleware-user-agent@3.796.0",
+ "@aws-sdk/types",
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/xml-builder@3.775.0": {
+ "integrity": "sha512-b9NGO6FKJeLGYnV7Z1yvcP1TNU4dkD5jNsLWOF1/sygZoASaQhNOlaiJ/1OH331YQ1R1oWk38nBb0frsYkDsOQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@hono/zod-validator@0.5.0_hono@4.7.10_zod@3.25.20": {
+ "integrity": "sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg==",
+ "dependencies": [
+ "hono",
+ "zod"
+ ]
+ },
+ "@libsql/darwin-arm64@0.3.19": {
+ "integrity": "sha512-rmOqsLcDI65zzxlUOoEiPJLhqmbFsZF6p4UJQ2kMqB+Kc0Rt5/A1OAdOZ/Wo8fQfJWjR1IbkbpEINFioyKf+nQ==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@libsql/darwin-x64@0.3.19": {
+ "integrity": "sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@libsql/linux-arm64-gnu@0.3.19": {
+ "integrity": "sha512-mgeAUU1oqqh57k7I3cQyU6Trpdsdt607eFyEmH5QO7dv303ti+LjUvh1pp21QWV6WX7wZyjeJV1/VzEImB+jRg==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@libsql/linux-arm64-musl@0.3.19": {
+ "integrity": "sha512-VEZtxghyK6zwGzU9PHohvNxthruSxBEnRrX7BSL5jQ62tN4n2JNepJ6SdzXp70pdzTfwroOj/eMwiPt94gkVRg==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@libsql/linux-x64-gnu@0.3.19": {
+ "integrity": "sha512-2t/J7LD5w2f63wGihEO+0GxfTyYIyLGEvTFEsMO16XI5o7IS9vcSHrxsvAJs4w2Pf907uDjmc7fUfMg6L82BrQ==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@libsql/linux-x64-musl@0.3.19": {
+ "integrity": "sha512-BLsXyJaL8gZD8+3W2LU08lDEd9MIgGds0yPy5iNPp8tfhXx3pV/Fge2GErN0FC+nzt4DYQtjL+A9GUMglQefXQ==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@libsql/win32-x64-msvc@0.3.19": {
+ "integrity": "sha512-ay1X9AobE4BpzG0XPw1gplyLZPGHIgJOovvW23gUrukRegiUP62uzhpRbKNogLlUOynyXeq//prHgPXiebUfWg==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@smithy/abort-controller@4.0.2": {
+ "integrity": "sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/chunked-blob-reader-native@4.0.0": {
+ "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==",
+ "dependencies": [
+ "@smithy/util-base64",
+ "tslib"
+ ]
+ },
+ "@smithy/chunked-blob-reader@5.0.0": {
+ "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/config-resolver@4.1.0": {
+ "integrity": "sha512-8smPlwhga22pwl23fM5ew4T9vfLUCeFXlcqNOCD5M5h8VmNPNUE9j6bQSuRXpDSV11L/E/SwEBQuW8hr6+nS1A==",
+ "dependencies": [
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "@smithy/util-config-provider",
+ "@smithy/util-middleware",
+ "tslib"
+ ]
+ },
+ "@smithy/core@3.2.0": {
+ "integrity": "sha512-k17bgQhVZ7YmUvA8at4af1TDpl0NDMBuBKJl8Yg0nrefwmValU+CnA5l/AriVdQNthU/33H3nK71HrLgqOPr1Q==",
+ "dependencies": [
+ "@smithy/middleware-serde",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-middleware",
+ "@smithy/util-stream",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/credential-provider-imds@4.0.2": {
+ "integrity": "sha512-32lVig6jCaWBHnY+OEQ6e6Vnt5vDHaLiydGrwYMW9tPqO688hPGTYRamYJ1EptxEC2rAwJrHWmPoKRBl4iTa8w==",
+ "dependencies": [
+ "@smithy/node-config-provider",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-codec@4.0.2": {
+ "integrity": "sha512-p+f2kLSK7ZrXVfskU/f5dzksKTewZk8pJLPvER3aFHPt76C2MxD9vNatSfLzzQSQB4FNO96RK4PSXfhD1TTeMQ==",
+ "dependencies": [
+ "@aws-crypto/crc32",
+ "@smithy/types",
+ "@smithy/util-hex-encoding",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-browser@4.0.2": {
+ "integrity": "sha512-CepZCDs2xgVUtH7ZZ7oDdZFH8e6Y2zOv8iiX6RhndH69nlojCALSKK+OXwZUgOtUZEUaZ5e1hULVCHYbCn7pug==",
+ "dependencies": [
+ "@smithy/eventstream-serde-universal",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-config-resolver@4.1.0": {
+ "integrity": "sha512-1PI+WPZ5TWXrfj3CIoKyUycYynYJgZjuQo8U+sphneOtjsgrttYybdqESFReQrdWJ+LKt6NEdbYzmmfDBmjX2A==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-node@4.0.2": {
+ "integrity": "sha512-C5bJ/C6x9ENPMx2cFOirspnF9ZsBVnBMtP6BdPl/qYSuUawdGQ34Lq0dMcf42QTjUZgWGbUIZnz6+zLxJlb9aw==",
+ "dependencies": [
+ "@smithy/eventstream-serde-universal",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-universal@4.0.2": {
+ "integrity": "sha512-St8h9JqzvnbB52FtckiHPN4U/cnXcarMniXRXTKn0r4b4XesZOGiAyUdj1aXbqqn1icSqBlzzUsCl6nPB018ng==",
+ "dependencies": [
+ "@smithy/eventstream-codec",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/fetch-http-handler@5.0.2": {
+ "integrity": "sha512-+9Dz8sakS9pe7f2cBocpJXdeVjMopUDLgZs1yWeu7h++WqSbjUYv/JAJwKwXw1HV6gq1jyWjxuyn24E2GhoEcQ==",
+ "dependencies": [
+ "@smithy/protocol-http",
+ "@smithy/querystring-builder",
+ "@smithy/types",
+ "@smithy/util-base64",
+ "tslib"
+ ]
+ },
+ "@smithy/hash-blob-browser@4.0.2": {
+ "integrity": "sha512-3g188Z3DyhtzfBRxpZjU8R9PpOQuYsbNnyStc/ZVS+9nVX1f6XeNOa9IrAh35HwwIZg+XWk8bFVtNINVscBP+g==",
+ "dependencies": [
+ "@smithy/chunked-blob-reader",
+ "@smithy/chunked-blob-reader-native",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/hash-node@4.0.2": {
+ "integrity": "sha512-VnTpYPnRUE7yVhWozFdlxcYknv9UN7CeOqSrMH+V877v4oqtVYuoqhIhtSjmGPvYrYnAkaM61sLMKHvxL138yg==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-buffer-from@4.0.0",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/hash-stream-node@4.0.2": {
+ "integrity": "sha512-POWDuTznzbIwlEXEvvXoPMS10y0WKXK790soe57tFRfvf4zBHyzE529HpZMqmDdwG9MfFflnyzndUQ8j78ZdSg==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/invalid-dependency@4.0.2": {
+ "integrity": "sha512-GatB4+2DTpgWPday+mnUkoumP54u/MDM/5u44KF9hIu8jF0uafZtQLcdfIKkIcUNuF/fBojpLEHZS/56JqPeXQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/is-array-buffer@2.2.0": {
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/is-array-buffer@4.0.0": {
+ "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/md5-js@4.0.2": {
+ "integrity": "sha512-Hc0R8EiuVunUewCse2syVgA2AfSRco3LyAv07B/zCOMa+jpXI9ll+Q21Nc6FAlYPcpNcAXqBzMhNs1CD/pP2bA==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-content-length@4.0.2": {
+ "integrity": "sha512-hAfEXm1zU+ELvucxqQ7I8SszwQ4znWMbNv6PLMndN83JJN41EPuS93AIyh2N+gJ6x8QFhzSO6b7q2e6oClDI8A==",
+ "dependencies": [
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-endpoint@4.1.0": {
+ "integrity": "sha512-xhLimgNCbCzsUppRTGXWkZywksuTThxaIB0HwbpsVLY5sceac4e1TZ/WKYqufQLaUy+gUSJGNdwD2jo3cXL0iA==",
+ "dependencies": [
+ "@smithy/core",
+ "@smithy/middleware-serde",
+ "@smithy/node-config-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "@smithy/util-middleware",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-retry@4.1.0": {
+ "integrity": "sha512-2zAagd1s6hAaI/ap6SXi5T3dDwBOczOMCSkkYzktqN1+tzbk1GAsHNAdo/1uzxz3Ky02jvZQwbi/vmDA6z4Oyg==",
+ "dependencies": [
+ "@smithy/node-config-provider",
+ "@smithy/protocol-http",
+ "@smithy/service-error-classification",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-middleware",
+ "@smithy/util-retry",
+ "tslib",
+ "uuid"
+ ]
+ },
+ "@smithy/middleware-serde@4.0.3": {
+ "integrity": "sha512-rfgDVrgLEVMmMn0BI8O+8OVr6vXzjV7HZj57l0QxslhzbvVfikZbVfBVthjLHqib4BW44QhcIgJpvebHlRaC9A==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-stack@4.0.2": {
+ "integrity": "sha512-eSPVcuJJGVYrFYu2hEq8g8WWdJav3sdrI4o2c6z/rjnYDd3xH9j9E7deZQCzFn4QvGPouLngH3dQ+QVTxv5bOQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/node-config-provider@4.0.2": {
+ "integrity": "sha512-WgCkILRZfJwJ4Da92a6t3ozN/zcvYyJGUTmfGbgS/FkCcoCjl7G4FJaCDN1ySdvLvemnQeo25FdkyMSTSwulsw==",
+ "dependencies": [
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/node-http-handler@4.0.4": {
+ "integrity": "sha512-/mdqabuAT3o/ihBGjL94PUbTSPSRJ0eeVTdgADzow0wRJ0rN4A27EOrtlK56MYiO1fDvlO3jVTCxQtQmK9dZ1g==",
+ "dependencies": [
+ "@smithy/abort-controller",
+ "@smithy/protocol-http",
+ "@smithy/querystring-builder",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/property-provider@4.0.2": {
+ "integrity": "sha512-wNRoQC1uISOuNc2s4hkOYwYllmiyrvVXWMtq+TysNRVQaHm4yoafYQyjN/goYZS+QbYlPIbb/QRjaUZMuzwQ7A==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/protocol-http@5.1.0": {
+ "integrity": "sha512-KxAOL1nUNw2JTYrtviRRjEnykIDhxc84qMBzxvu1MUfQfHTuBlCG7PA6EdVwqpJjH7glw7FqQoFxUJSyBQgu7g==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/querystring-builder@4.0.2": {
+ "integrity": "sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-uri-escape",
+ "tslib"
+ ]
+ },
+ "@smithy/querystring-parser@4.0.2": {
+ "integrity": "sha512-v6w8wnmZcVXjfVLjxw8qF7OwESD9wnpjp0Dqry/Pod0/5vcEA3qxCr+BhbOHlxS8O+29eLpT3aagxXGwIoEk7Q==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/service-error-classification@4.0.2": {
+ "integrity": "sha512-LA86xeFpTKn270Hbkixqs5n73S+LVM0/VZco8dqd+JT75Dyx3Lcw/MraL7ybjmz786+160K8rPOmhsq0SocoJQ==",
+ "dependencies": [
+ "@smithy/types"
+ ]
+ },
+ "@smithy/shared-ini-file-loader@4.0.2": {
+ "integrity": "sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/signature-v4@5.0.2": {
+ "integrity": "sha512-Mz+mc7okA73Lyz8zQKJNyr7lIcHLiPYp0+oiqiMNc/t7/Kf2BENs5d63pEj7oPqdjaum6g0Fc8wC78dY1TgtXw==",
+ "dependencies": [
+ "@smithy/is-array-buffer@4.0.0",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-hex-encoding",
+ "@smithy/util-middleware",
+ "@smithy/util-uri-escape",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/signature-v4@5.1.0": {
+ "integrity": "sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w==",
+ "dependencies": [
+ "@smithy/is-array-buffer@4.0.0",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-hex-encoding",
+ "@smithy/util-middleware",
+ "@smithy/util-uri-escape",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/smithy-client@4.2.0": {
+ "integrity": "sha512-Qs65/w30pWV7LSFAez9DKy0Koaoh3iHhpcpCCJ4waj/iqwsuSzJna2+vYwq46yBaqO5ZbP9TjUsATUNxrKeBdw==",
+ "dependencies": [
+ "@smithy/core",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-stack",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-stream",
+ "tslib"
+ ]
+ },
+ "@smithy/types@4.2.0": {
+ "integrity": "sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/url-parser@4.0.2": {
+ "integrity": "sha512-Bm8n3j2ScqnT+kJaClSVCMeiSenK6jVAzZCNewsYWuZtnBehEz4r2qP0riZySZVfzB+03XZHJeqfmJDkeeSLiQ==",
+ "dependencies": [
+ "@smithy/querystring-parser",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-base64@4.0.0": {
+ "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==",
+ "dependencies": [
+ "@smithy/util-buffer-from@4.0.0",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/util-body-length-browser@4.0.0": {
+ "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/util-body-length-node@4.0.0": {
+ "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/util-buffer-from@2.2.0": {
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "dependencies": [
+ "@smithy/is-array-buffer@2.2.0",
+ "tslib"
+ ]
+ },
+ "@smithy/util-buffer-from@4.0.0": {
+ "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==",
+ "dependencies": [
+ "@smithy/is-array-buffer@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/util-config-provider@4.0.0": {
+ "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/util-defaults-mode-browser@4.0.8": {
+ "integrity": "sha512-ZTypzBra+lI/LfTYZeop9UjoJhhGRTg3pxrNpfSTQLd3AJ37r2z4AXTKpq1rFXiiUIJsYyFgNJdjWRGP/cbBaQ==",
+ "dependencies": [
+ "@smithy/property-provider",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "bowser",
+ "tslib"
+ ]
+ },
+ "@smithy/util-defaults-mode-node@4.0.8": {
+ "integrity": "sha512-Rgk0Jc/UDfRTzVthye/k2dDsz5Xxs9LZaKCNPgJTRyoyBoeiNCnHsYGOyu1PKN+sDyPnJzMOz22JbwxzBp9NNA==",
+ "dependencies": [
+ "@smithy/config-resolver",
+ "@smithy/credential-provider-imds",
+ "@smithy/node-config-provider",
+ "@smithy/property-provider",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-endpoints@3.0.2": {
+ "integrity": "sha512-6QSutU5ZyrpNbnd51zRTL7goojlcnuOB55+F9VBD+j8JpRY50IGamsjlycrmpn8PQkmJucFW8A0LSfXj7jjtLQ==",
+ "dependencies": [
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-hex-encoding@4.0.0": {
+ "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/util-middleware@4.0.2": {
+ "integrity": "sha512-6GDamTGLuBQVAEuQ4yDQ+ti/YINf/MEmIegrEeg7DdB/sld8BX1lqt9RRuIcABOhAGTA50bRbPzErez7SlDtDQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-retry@4.0.2": {
+ "integrity": "sha512-Qryc+QG+7BCpvjloFLQrmlSd0RsVRHejRXd78jNO3+oREueCjwG1CCEH1vduw/ZkM1U9TztwIKVIi3+8MJScGg==",
+ "dependencies": [
+ "@smithy/service-error-classification",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-stream@4.2.0": {
+ "integrity": "sha512-Vj1TtwWnuWqdgQI6YTUF5hQ/0jmFiOYsc51CSMgj7QfyO+RF4EnT2HNjoviNlOOmgzgvf3f5yno+EiC4vrnaWQ==",
+ "dependencies": [
+ "@smithy/fetch-http-handler",
+ "@smithy/node-http-handler",
+ "@smithy/types",
+ "@smithy/util-base64",
+ "@smithy/util-buffer-from@4.0.0",
+ "@smithy/util-hex-encoding",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/util-uri-escape@4.0.0": {
+ "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/util-utf8@2.3.0": {
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "dependencies": [
+ "@smithy/util-buffer-from@2.2.0",
+ "tslib"
+ ]
+ },
+ "@smithy/util-utf8@4.0.0": {
+ "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==",
+ "dependencies": [
+ "@smithy/util-buffer-from@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/util-waiter@4.0.3": {
+ "integrity": "sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA==",
+ "dependencies": [
+ "@smithy/abort-controller",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@types/node@22.15.15": {
+ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
+ "dependencies": [
+ "undici-types"
+ ]
+ },
+ "bowser@2.11.0": {
+ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="
+ },
+ "email-addresses@5.0.0": {
+ "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw=="
+ },
+ "fast-xml-parser@4.4.1": {
+ "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==",
+ "dependencies": [
+ "strnum"
+ ],
+ "bin": true
+ },
+ "hono@4.7.10": {
+ "integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="
+ },
+ "kysely@0.28.2": {
+ "integrity": "sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A=="
+ },
+ "strnum@1.1.2": {
+ "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="
+ },
+ "tslib@2.8.1": {
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "undici-types@6.21.0": {
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+ },
+ "uuid@9.0.1": {
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "bin": true
+ },
+ "ws@8.18.1": {
+ "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="
+ },
+ "zod@3.25.20": {
+ "integrity": "sha512-z03fqpTMDF1G02VLKUMt6vyACE7rNWkh3gpXVHgPTw28NPtDFRGvcpTtPwn2kMKtQ0idtYJUTxchytmnqYswcw=="
+ }
+ },
+ "workspace": {
+ "dependencies": [
+ "jsr:@db/sqlite@0.12",
+ "jsr:@std/cli@^1.0.17",
+ "jsr:@std/encoding@^1.0.10",
+ "jsr:@std/expect@^1.0.16",
+ "jsr:@std/path@^1.0.9",
+ "jsr:@std/testing@^1.0.12",
+ "npm:@aws-sdk/client-s3@^3.797.0",
+ "npm:@aws-sdk/client-sesv2@^3.782.0",
+ "npm:@hono/zod-validator@0.5",
+ "npm:email-addresses@5",
+ "npm:hono@^4.7.10",
+ "npm:kysely@~0.28.2",
+ "npm:zod@^3.25.20"
+ ]
+ }
+}
diff --git a/services/docker/mail-server/relay/dovecot.ts b/services/docker/mail-server/relay/dovecot.ts
new file mode 100644
index 0000000..0528968
--- /dev/null
+++ b/services/docker/mail-server/relay/dovecot.ts
@@ -0,0 +1,83 @@
+import { getConfig } from "./config.ts";
+import { getLogger } from "./logger.ts";
+import {
+ Mail,
+ MailDeliverContext,
+ MailDeliverer,
+ MailDeliverReceiptResult,
+} from "./mail.ts";
+
+export class DovecotMailDeliverer extends MailDeliverer {
+ readonly name = "dovecot";
+ readonly ldaBin = "dovecot-lda";
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ const { ldaBin } = this;
+ const utf8Stream = mail.toUtf8Bytes();
+ const receipts = mail.simpleParseReceipts({
+ domain: getConfig().getValue("mailDomain"),
+ });
+
+ if (receipts.length === 0) {
+ throw new Error("No receipts detected from mail headers.");
+ }
+
+ getLogger().log(`Deliver to (from headers) ${receipts.join(", ")}.`);
+
+ for (const receipt of receipts) {
+ getLogger().log(`Call ${ldaBin} for ${receipt}...`);
+
+ const result: MailDeliverReceiptResult = {
+ kind: "done",
+ message: `${ldaBin} exited with success.`,
+ };
+
+ try {
+ const ldaCommand = new Deno.Command(ldaBin, {
+ args: ["-d", receipt],
+ stdin: "piped",
+ stdout: "piped",
+ stderr: "piped",
+ });
+
+ const ldaProcess = ldaCommand.spawn();
+ getLogger().logProgramOutput(ldaProcess, ldaBin);
+
+ const stdinWriter = ldaProcess.stdin.getWriter();
+ await stdinWriter.ready;
+ await stdinWriter.write(utf8Stream);
+ await stdinWriter.ready;
+ const status = await ldaProcess.status;
+
+ if (!status.success) {
+ result.kind = "fail";
+ result.message = `${ldaBin} exited with error code ${status.code}`;
+
+ if (status.signal != null) {
+ result.message += ` (signal ${status.signal})`;
+ }
+
+ // https://doc.dovecot.org/main/core/man/dovecot-lda.1.html
+ switch (status.code) {
+ case 67:
+ result.message += ", receipt user not known";
+ break;
+ case 75:
+ result.kind = "retry";
+ break;
+ }
+
+ result.message += ".";
+ }
+ } catch (e) {
+ result.kind = "fail";
+ result.message = "An error was thrown when running lda process.";
+ result.cause = e;
+ }
+ context.result.set(receipt, result);
+ }
+ }
+}
diff --git a/services/docker/mail-server/relay/logger.ts b/services/docker/mail-server/relay/logger.ts
new file mode 100644
index 0000000..1082332
--- /dev/null
+++ b/services/docker/mail-server/relay/logger.ts
@@ -0,0 +1,56 @@
+import * as path from "@std/path";
+
+import { createSingleton, generateTimeStringForFileName } from "./util.ts";
+
+export class Logger {
+ constructor(public readonly path: string) {
+ Deno.mkdirSync(path, { recursive: true });
+ }
+
+ log(...args: unknown[]) {
+ console.log(...args);
+ }
+
+ warn(...args: unknown[]) {
+ console.warn(...args);
+ }
+
+ generateLogFilePath(
+ prefix: string = "",
+ suffix: string = "",
+ instant?: Temporal.Instant | Date,
+ ): string {
+ return path.join(
+ this.path,
+ `${prefix}-${generateTimeStringForFileName(instant)}-${suffix}`,
+ );
+ }
+
+ openLogFile(
+ prefix: string = "",
+ suffix: string = "",
+ instant?: Temporal.Instant | Date,
+ ): Promise<Deno.FsFile> {
+ const logPath = this.generateLogFilePath(prefix, suffix, instant);
+ return Deno.open(logPath, {
+ read: false,
+ write: true,
+ append: true,
+ create: true,
+ });
+ }
+
+ async logProgramOutput(
+ process: Deno.ChildProcess,
+ program: string,
+ instant?: Temporal.Instant | Date,
+ ): Promise<void> {
+ const stdoutFile = await this.openLogFile(program, "stdout", instant);
+ const stderrFile = await this.openLogFile(program, "stderr", instant);
+
+ process.stdout.pipeTo(stdoutFile.writable);
+ process.stderr.pipeTo(stderrFile.writable);
+ }
+}
+
+export const [getLogger, setLogger] = createSingleton<Logger>("Logger");
diff --git a/services/docker/mail-server/relay/mail.test.ts b/services/docker/mail-server/relay/mail.test.ts
new file mode 100644
index 0000000..1741efc
--- /dev/null
+++ b/services/docker/mail-server/relay/mail.test.ts
@@ -0,0 +1,115 @@
+import { describe, it } from "@std/testing/bdd";
+import { expect, fn } from "@std/expect";
+
+import { Mail, MailDeliverer } from "./mail.ts";
+
+const mockDate = "Fri, 02 May 2025 08:33:02 +0000";
+const mockBodyStr = `This is body content.
+Line 2
+
+Line 4`;
+const mockHeaders = [
+ ["Content-Disposition", "inline"],
+ ["Content-Transfer-Encoding", "quoted-printable"],
+ ["MIME-Version", "1.0"],
+ ["X-Mailer", "MIME-tools 5.509 (Entity 5.509)"],
+ ["Content-Type", "text/plain; charset=utf-8"],
+ ["From", '"Mock From" <mock@from.mock>'],
+ [
+ "To",
+ `"John \\"Big\\" Doe" <john@example.com>, "Alice (Work)" <alice+work@example.com>,
+ undisclosed-recipients:;, "Group: Team" <team@company.com>,
+ "Escaped, Name" <escape@test.com>, just@email.com,
+ "Comment (This is valid)" <comment@domain.net>,
+ "Odd @Chars" <weird!#$%'*+-/=?^_\`{|}~@char-test.com>,
+ "Non-ASCII 用户" <user@例子.中国>,
+ admin@[192.168.1.1]`,
+ ],
+ ["CC", "Mock CC <mock@cc.mock>"],
+ ["Subject", "A very long mock\n subject"],
+ ["Message-ID", "<abcdef@from.mock>"],
+ ["Date", mockDate],
+];
+const mockHeaderStr = mockHeaders.map((h) => h[0] + ": " + h[1]).join("\n");
+const mockMailStr = mockHeaderStr + "\n\n" + mockBodyStr;
+const mockCrlfMailStr = mockMailStr.replaceAll("\n", "\r\n");
+const mockToAddresses = [
+ "john@example.com",
+ "alice+work@example.com",
+ "team@company.com",
+ "escape@test.com",
+ "just@email.com",
+ "comment@domain.net",
+ "weird!#$%'*+-/=?^_`{|}~@char-test.com",
+ "user@例子.中国",
+ "admin@[192.168.1.1]",
+];
+const mockCcAddresses = ["mock@cc.mock"];
+
+describe("Mail", () => {
+ it("simple parse", () => {
+ const parsed = new Mail(mockMailStr).simpleParse();
+ expect(parsed.sections.header).toEqual(mockHeaderStr);
+ expect(parsed.sections.body).toEqual(mockBodyStr);
+ expect(parsed.sep).toBe("\n");
+ expect(parsed.eol).toBe("\n");
+ });
+
+ it("simple parse crlf", () => {
+ const parsed = new Mail(mockCrlfMailStr).simpleParse();
+ expect(parsed.sep).toBe("\r\n");
+ expect(parsed.eol).toBe("\r\n");
+ });
+
+ it("simple parse date", () => {
+ expect(new Mail(mockMailStr).simpleParseDate()).toEqual(new Date(mockDate));
+ });
+
+ it("simple parse headers", () => {
+ expect(
+ new Mail(mockMailStr).simpleParseHeaders(),
+ ).toEqual(mockHeaders.map(
+ (h) => [h[0], " " + h[1].replaceAll("\n", "")],
+ ));
+ });
+
+ it("append headers", () => {
+ const mail = new Mail(mockMailStr);
+ const mockMoreHeaders = [["abc", "123"], ["def", "456"]] satisfies [
+ string,
+ string,
+ ][];
+ mail.appendHeaders(mockMoreHeaders);
+
+ expect(mail.raw).toBe(
+ mockHeaderStr + "\n" +
+ mockMoreHeaders.map((h) => h[0] + ": " + h[1]).join("\n") +
+ "\n\n" + mockBodyStr,
+ );
+ });
+
+ it("parse receipts", () => {
+ const mail = new Mail(mockMailStr);
+ expect(mail.simpleParseReceipts()).toEqual([
+ ...mockToAddresses,
+ ...mockCcAddresses,
+ ]);
+ expect(mail.simpleParseReceipts({domain: "example.com"})).toEqual([
+ ...mockToAddresses,
+ ...mockCcAddresses,
+ ].filter(a => a.endsWith("example.com")));
+ });
+});
+
+describe("MailDeliverer", () => {
+ class MockMailDeliverer extends MailDeliverer {
+ name = "mock";
+ override doDeliver = fn() as MailDeliverer["doDeliver"];
+ }
+ const mockDeliverer = new MockMailDeliverer();
+
+ it("deliver success", async () => {
+ await mockDeliverer.deliverRaw(mockMailStr);
+ expect(mockDeliverer.doDeliver).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/services/docker/mail-server/relay/mail.ts b/services/docker/mail-server/relay/mail.ts
new file mode 100644
index 0000000..a16bdfa
--- /dev/null
+++ b/services/docker/mail-server/relay/mail.ts
@@ -0,0 +1,240 @@
+import { encodeBase64 } from "@std/encoding/base64";
+import emailAddresses from "email-addresses";
+
+import { getLogger } from "./logger.ts";
+
+class MailParseError extends Error {
+ constructor(
+ message: string,
+ public readonly mail: Mail,
+ public readonly lineNumber?: number,
+ options?: ErrorOptions,
+ ) {
+ if (lineNumber != null) message += `(at line ${lineNumber})`;
+ super(message, options);
+ }
+}
+
+interface ParsedMail {
+ sections: {
+ header: string;
+ body: string;
+ };
+ /**
+ * The empty line between headers and body.
+ */
+ sep: string;
+ eol: string;
+}
+
+export class Mail {
+ date?: Date;
+ messageId?: string;
+
+ constructor(public raw: string) {}
+
+ toUtf8Bytes(): Uint8Array {
+ const utf8Encoder = new TextEncoder();
+ return utf8Encoder.encode(this.raw);
+ }
+
+ toBase64(): string {
+ return encodeBase64(this.raw);
+ }
+
+ simpleParse(): ParsedMail {
+ const twoEolMatch = this.raw.match(/(\r?\n)(\r?\n)/);
+ if (twoEolMatch == null) {
+ throw new MailParseError(
+ "No header/body section separator (2 successive EOLs) found.",
+ this,
+ );
+ }
+
+ const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]];
+
+ if (eol !== sep) {
+ getLogger().warn("Different EOLs (\\r\\n, \\n) found.");
+ }
+
+ return {
+ sections: {
+ header: this.raw.slice(0, twoEolMatch.index!),
+ body: this.raw.slice(twoEolMatch.index! + eol.length + sep.length),
+ },
+ sep,
+ eol,
+ };
+ }
+
+ simpleParseHeaders(): [key: string, value: string][] {
+ const { sections } = this.simpleParse();
+ const headers: [string, string][] = [];
+
+ let field: string | null = null;
+ let lineNumber = 1;
+
+ const handleField = () => {
+ if (field == null) return;
+ const sepPos = field.indexOf(":");
+ if (sepPos === -1) {
+ throw new MailParseError(
+ "No ':' in the header field.",
+ this,
+ lineNumber,
+ );
+ }
+ headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]);
+ field = null;
+ };
+
+ for (const line of sections.header.trimEnd().split(/\r?\n|\r/)) {
+ if (line.match(/^\s/)) {
+ if (field == null) {
+ throw new MailParseError(
+ "Header field starts with a space.",
+ this,
+ lineNumber,
+ );
+ }
+ field += line;
+ } else {
+ handleField();
+ field = line;
+ }
+ lineNumber += 1;
+ }
+
+ handleField();
+
+ return headers;
+ }
+
+ simpleParseDate<T = undefined>(
+ invalidValue: T | undefined = undefined,
+ ): Date | T | undefined {
+ const headers = this.simpleParseHeaders();
+ for (const [key, value] of headers) {
+ if (key.toLowerCase() === "date") {
+ const date = new Date(value);
+ if (isNaN(date.getTime())) {
+ getLogger().warn(`Invalid date string (${value}) found in header.`);
+ return invalidValue;
+ }
+ return date;
+ }
+ }
+ return undefined;
+ }
+
+ simpleParseReceipts(
+ options?: { domain?: string; headers?: string[] },
+ ): string[] {
+ const headers = options?.headers ?? ["to", "cc", "bcc", "x-original-to"];
+ const receipts = new Set<string>();
+ for (const [key, value] of this.simpleParseHeaders()) {
+ if (headers.includes(key.toLowerCase())) {
+ emailAddresses.parseAddressList(value)?.flatMap((a) =>
+ a.type === "mailbox" ? a.address : a.addresses.map((a) => a.address)
+ )?.forEach((a) => receipts.add(a));
+ }
+ }
+ const domain = options?.domain;
+ if (domain != null) {
+ return [...receipts].filter((r) => r.endsWith(domain));
+ }
+ return [...receipts];
+ }
+
+ // TODO: Add folding.
+ appendHeaders(headers: [key: string, value: string][]) {
+ const { sections, sep, eol } = this.simpleParse();
+
+ this.raw = sections.header + eol +
+ headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep +
+ sections.body;
+ }
+}
+
+export type MailDeliverResultKind = "done" | "fail" | "retry";
+
+export interface MailDeliverReceiptResult {
+ kind: MailDeliverResultKind;
+ message: string;
+ cause?: unknown;
+}
+
+export class MailDeliverResult {
+ readonly receipts: Map<string, MailDeliverReceiptResult> = new Map();
+
+ add(
+ receipt: string,
+ kind: MailDeliverResultKind,
+ message: string,
+ cause?: unknown,
+ ) {
+ this.receipts.set(receipt, { kind, message, cause });
+ }
+
+ set(receipt: string, result: MailDeliverReceiptResult) {
+ this.receipts.set(receipt, result);
+ }
+
+ [Symbol.for("Deno.customInspect")]() {
+ return [
+ ...this.receipts.entries().map(([receipt, result]) =>
+ `${receipt}[${result.kind}]: ${result.message}`
+ ),
+ ].join("\n");
+ }
+}
+
+export class MailDeliverContext {
+ readonly result = new MailDeliverResult();
+ constructor(public mail: Mail) {
+ }
+}
+
+export type MailDeliverHook = (context: MailDeliverContext) => Promise<void>;
+
+export abstract class MailDeliverer {
+ abstract readonly name: string;
+ preHooks: MailDeliverHook[] = [];
+ postHooks: MailDeliverHook[] = [];
+
+ protected abstract doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void>;
+
+ async deliverRaw(rawMail: string): Promise<Mail> {
+ const mail = new Mail(rawMail);
+ await this.deliver(mail);
+ return mail;
+ }
+
+ async deliver(mail: Mail): Promise<MailDeliverResult> {
+ getLogger().log(`Begin to deliver mail via ${this.name}...`);
+
+ const context = new MailDeliverContext(mail);
+
+ for (const hook of this.preHooks) {
+ await hook(context);
+ }
+
+ await this.doDeliver(context.mail, context);
+
+ for (const hook of this.postHooks) {
+ await hook(context);
+ }
+
+ getLogger().log("Deliver result:", context.result);
+
+ if (context.result.receipts.values().some((r) => r.kind !== "done")) {
+ getLogger().warn(context.result);
+ throw new Error("Mail failed to deliver.");
+ }
+
+ return context.result;
+ }
+}
diff --git a/services/docker/mail-server/relay/main.ts b/services/docker/mail-server/relay/main.ts
new file mode 100644
index 0000000..7f91179
--- /dev/null
+++ b/services/docker/mail-server/relay/main.ts
@@ -0,0 +1,20 @@
+import { parseArgs } from "@std/cli";
+
+import { AwsRelayApp } from "./aws/app.ts";
+
+if (import.meta.main) {
+ const app = new AwsRelayApp();
+
+ const args = parseArgs(Deno.args);
+ if (args._.length === 0) {
+ throw new Error("You must specify a command.");
+ }
+
+ const command = args._[0];
+ if (command in app.cliCommands) {
+ await app.cliCommands[command]();
+ Deno.exit(0);
+ } else {
+ throw new Error(command + " is not a valid command.");
+ }
+}
diff --git a/services/docker/mail-server/relay/util.ts b/services/docker/mail-server/relay/util.ts
new file mode 100644
index 0000000..29cda8b
--- /dev/null
+++ b/services/docker/mail-server/relay/util.ts
@@ -0,0 +1,60 @@
+function getZonedDateTime(instant?: Temporal.Instant | Date) {
+ if (instant == null) {
+ instant = Temporal.Now.instant();
+ } else if (instant instanceof Date) {
+ instant = instant.toTemporalInstant();
+ }
+
+ return instant.toZonedDateTimeISO("UTC");
+}
+
+export function generateTimeStringForFileName(
+ instant?: Temporal.Instant | Date,
+ dateOnly: boolean = false,
+): string {
+ const time = getZonedDateTime(instant);
+
+ if (dateOnly) {
+ return time.toPlainDate().toString();
+ } else {
+ return time.toPlainDateTime().toString().replaceAll(/:|\./g, "-");
+ }
+}
+
+export function transformProperties<T extends object, N>(
+ object: T,
+ transformer: (value: T[keyof T], key: keyof T) => N,
+): { [k in keyof T]: N } {
+ return Object.fromEntries(
+ Object.entries(object).map((
+ [k, v],
+ ) => [k, transformer(v, k as keyof T)]),
+ ) as { [k in keyof T]: N };
+}
+
+export function createSingleton<T>(
+ name: string,
+): [() => T, (v: T | null) => void] {
+ let singleton: T | null = null;
+
+ return [
+ () => {
+ if (singleton == null) {
+ throw new Error(`Singleton ${name} is not set now.`);
+ }
+ return singleton;
+ },
+ (newValue: T | null) => singleton = newValue,
+ ];
+}
+
+export function withDefaults<T extends object>(
+ partial: Partial<T> | null | undefined,
+ defaults: T,
+): T {
+ const result: Record<PropertyKey, unknown> = {};
+ for (const [key, value] of Object.entries(defaults)) {
+ result[key] = partial?.[key as keyof T] ?? value;
+ }
+ return result as T;
+}
diff --git a/services/docker/nginx/configs/templates/mail.conf.template b/services/docker/nginx/configs/templates/mail.conf.template
index 7f5f215..430033c 100644
--- a/services/docker/nginx/configs/templates/mail.conf.template
+++ b/services/docker/nginx/configs/templates/mail.conf.template
@@ -11,11 +11,6 @@ server {
proxy_pass http://roundcubemail:80/;
}
- location /rspamd/ {
- include common/proxy-common;
- proxy_pass http://mailserver:11334/;
- }
-
client_max_body_size 5G;
}
diff --git a/services/templates/docker-compose.yaml.template b/services/templates/docker-compose.yaml.template
index ddd3c95..d8f9f04 100644
--- a/services/templates/docker-compose.yaml.template
+++ b/services/templates/docker-compose.yaml.template
@@ -46,35 +46,30 @@ services:
- "./data/auto-backup:/data/auto-backup"
restart: on-failure:3
- mailserver:
- image: ghcr.io/docker-mailserver/docker-mailserver:latest
- pull_policy: always
- container_name: mailserver
- hostname: mail.@@CRUPEST_DOMAIN@@
- env_file: ./@@CRUPEST_GENERATED_DIR@@/envs/mailserver.env
- # More information about the mail-server ports:
- # https://docker-mailserver.github.io/docker-mailserver/edge/config/security/understanding-the-ports/
- # To avoid conflicts with yaml base-60 float, DO NOT remove the quotation marks.
+ mail-server:
+ pull_policy: build
+ build:
+ context: ./@@CRUPEST_DOCKER_DIR@@/mail-server
+ dockerfile: Dockerfile
+ pull: true
+ container_name: mail-server
+ hostname: mail
+ domainname: "@@CRUPEST_DOMAIN@@"
+ env_file:
+ - "./@@CRUPEST_GENERATED_DIR@@/envs/mail-server.env"
ports:
- - "25:25" # SMTP (explicit TLS => STARTTLS)
- "143:143" # IMAP4 (explicit TLS => STARTTLS)
- - "465:465" # ESMTP (implicit TLS)
- - "587:587" # ESMTP (explicit TLS => STARTTLS)
- "993:993" # IMAP4 (implicit TLS)
+ - "587:587" # ESMTP (explicit TLS => STARTTLS)
+ - "465:465" # ESMTP (implicit TLS)
- "4190:4190" # manage sieve protocol
volumes:
- - ./@@CRUPEST_DATA_MAILSERVER_DIR@@/mail-data/:/var/mail/
- - ./@@CRUPEST_SERVICES_STATE_DIR@@/mail-state/:/var/mail-state/
- - ./@@CRUPEST_DATA_MAILSERVER_DIR@@/mail-logs/:/var/log/mail/
- - ./@@CRUPEST_DATA_MAILSERVER_DIR@@/config/:/tmp/docker-mailserver/
- - ./@@CRUPEST_DATA_CERTBOT_DIR@@/certs:/etc/letsencrypt
- - /etc/localtime:/etc/localtime:ro
+ - "./@@CRUPEST_DATA_MAIL_SERVER_DIR@@:/data"
+ - "./@@CRUPEST_SSL_FULLCHAIN_FILE@@:/etc/dovecot/ssl/tls.crt"
+ - "./@@CRUPEST_SSL_PRIVATE_KEY_FILE@@:/etc/dovecot/ssl/tls.key"
+ - "/etc/localtime:/etc/localtime:ro"
restart: on-failure:3
stop_grace_period: 1m
- healthcheck:
- test: "ss --listening --tcp | grep -P 'LISTEN.+:smtp' || exit 1"
- timeout: 3s
- retries: 0
git-server:
pull_policy: build
diff --git a/services/templates/envs/mail-server.env.template b/services/templates/envs/mail-server.env.template
new file mode 100644
index 0000000..c34d356
--- /dev/null
+++ b/services/templates/envs/mail-server.env.template
@@ -0,0 +1,4 @@
+CRUPEST_MAILSERVER_MAIL_DOMAIN=@@CRUPEST_DOMAIN@@
+CRUPEST_MAILSERVER_AWS_USER=@@CRUPEST_MAILSERVER_AWS_USER@@
+CRUPEST_MAILSERVER_AWS_PASSWORD=@@CRUPEST_MAILSERVER_AWS_PASSWORD@@
+CRUPEST_MAILSERVER_AWS_MAIL_BUCKET=@@CRUPEST_MAILSERVER_AWS_MAIL_BUCKET@@
diff --git a/services/templates/envs/mailserver.env.template b/services/templates/envs/mailserver.env.template
deleted file mode 100644
index dffb0e8..0000000
--- a/services/templates/envs/mailserver.env.template
+++ /dev/null
@@ -1,661 +0,0 @@
-# -----------------------------------------------
-# --- Mailserver Environment Variables ----------
-# -----------------------------------------------
-
-# DOCUMENTATION FOR THESE VARIABLES IS FOUND UNDER
-# https://docker-mailserver.github.io/docker-mailserver/latest/config/environment/
-
-# -----------------------------------------------
-# --- General Section ---------------------------
-# -----------------------------------------------
-
-# empty => uses the `hostname` command to get the mail server's canonical hostname
-# => Specify a fully-qualified domainname to serve mail for. This is used for many of the config features so if you can't set your hostname (e.g. you're in a container platform that doesn't let you) specify it in this environment variable.
-OVERRIDE_HOSTNAME=
-
-# REMOVED in version v11.0.0! Use LOG_LEVEL instead.
-DMS_DEBUG=0
-
-# Set the log level for DMS.
-# This is mostly relevant for container startup scripts and change detection event feedback.
-#
-# Valid values (in order of increasing verbosity) are: `error`, `warn`, `info`, `debug` and `trace`.
-# The default log level is `info`.
-LOG_LEVEL=info
-
-# critical => Only show critical messages
-# error => Only show erroneous output
-# **warn** => Show warnings
-# info => Normal informational output
-# debug => Also show debug messages
-SUPERVISOR_LOGLEVEL=
-
-# Support for deployment where these defaults are not compatible (eg: some NAS appliances):
-# /var/mail vmail User ID (default: 5000)
-DMS_VMAIL_UID=
-# /var/mail vmail Group ID (default: 5000)
-DMS_VMAIL_GID=
-
-# **empty** => use FILE
-# LDAP => use LDAP authentication
-# OIDC => use OIDC authentication (not yet implemented)
-# FILE => use local files (this is used as the default)
-ACCOUNT_PROVISIONER=
-
-# empty => postmaster@domain.com
-# => Specify the postmaster address
-POSTMASTER_ADDRESS=
-
-# Check for updates on container start and then once a day
-# If an update is available, a mail is sent to POSTMASTER_ADDRESS
-# 0 => Update check disabled
-# 1 => Update check enabled
-ENABLE_UPDATE_CHECK=1
-
-# Customize the update check interval.
-# Number + Suffix. Suffix must be 's' for seconds, 'm' for minutes, 'h' for hours or 'd' for days.
-UPDATE_CHECK_INTERVAL=1d
-
-# Set different options for mynetworks option (can be overwrite in postfix-main.cf)
-# **WARNING**: Adding the docker network's gateway to the list of trusted hosts, e.g. using the `network` or
-# `connected-networks` option, can create an open relay
-# https://github.com/docker-mailserver/docker-mailserver/issues/1405#issuecomment-590106498
-# The same can happen for rootless podman. To prevent this, set the value to "none" or configure slirp4netns
-# https://github.com/docker-mailserver/docker-mailserver/issues/2377
-#
-# none => Explicitly force authentication
-# container => Container IP address only
-# host => Add docker container network (ipv4 only)
-# network => Add all docker container networks (ipv4 only)
-# connected-networks => Add all connected docker networks (ipv4 only)
-PERMIT_DOCKER=none
-
-# Set the timezone. If this variable is unset, the container runtime will try to detect the time using
-# `/etc/localtime`, which you can alternatively mount into the container. The value of this variable
-# must follow the pattern `AREA/ZONE`, i.e. of you want to use Germany's time zone, use `Europe/Berlin`.
-# You can lookup all available timezones here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
-TZ=
-
-# In case you network interface differs from 'eth0', e.g. when you are using HostNetworking in Kubernetes,
-# you can set NETWORK_INTERFACE to whatever interface you want. This interface will then be used.
-# - **empty** => eth0
-NETWORK_INTERFACE=
-
-# empty => modern
-# modern => Enables TLSv1.2 and modern ciphers only. (default)
-# intermediate => Enables TLSv1, TLSv1.1 and TLSv1.2 and broad compatibility ciphers.
-TLS_LEVEL=
-
-# Configures the handling of creating mails with forged sender addresses.
-#
-# **0** => (not recommended) Mail address spoofing allowed. Any logged in user may create email messages with a forged sender address (see also https://en.wikipedia.org/wiki/Email_spoofing).
-# 1 => Mail spoofing denied. Each user may only send with their own or their alias addresses. Addresses with extension delimiters(http://www.postfix.org/postconf.5.html#recipient_delimiter) are not able to send messages.
-SPOOF_PROTECTION=
-
-# Enables the Sender Rewriting Scheme. SRS is needed if your mail server acts as forwarder. See [postsrsd](https://github.com/roehling/postsrsd/blob/main/README.rst) for further explanation.
-# - **0** => Disabled
-# - 1 => Enabled
-ENABLE_SRS=0
-
-# Enables the OpenDKIM service.
-# **1** => Enabled
-# 0 => Disabled
-ENABLE_OPENDKIM=0
-
-# Enables the OpenDMARC service.
-# **1** => Enabled
-# 0 => Disabled
-ENABLE_OPENDMARC=0
-
-
-# Enabled `policyd-spf` in Postfix's configuration. You will likely want to set this
-# to `0` in case you're using Rspamd (`ENABLE_RSPAMD=1`).
-#
-# - 0 => Disabled
-# - **1** => Enabled
-ENABLE_POLICYD_SPF=0
-
-# Enables POP3 service
-# - **0** => Disabled
-# - 1 => Enabled
-ENABLE_POP3=
-
-# Enables IMAP service
-# - 0 => Disabled
-# - **1** => Enabled
-ENABLE_IMAP=1
-
-# Enables ClamAV, and anti-virus scanner.
-# 1 => Enabled
-# **0** => Disabled
-ENABLE_CLAMAV=0
-
-# Add the value of this ENV as a prefix to the mail subject when spam is detected.
-# NOTE: This subject prefix may be redundant (by default spam is delivered to a junk folder).
-# It provides value when your junk mail is stored alongside legitimate mail instead of a separate location (like with `SPAMASSASSIN_SPAM_TO_INBOX=1` or `MOVE_SPAM_TO_JUNK=0` or a POP3 only setup, without IMAP).
-# NOTE: When not using Docker Compose, other CRI may not support quote-wrapping the value here to preserve any trailing white-space.
-SPAM_SUBJECT=
-
-# Enables Rspamd
-# **0** => Disabled
-# 1 => Enabled
-ENABLE_RSPAMD=1
-
-# When `ENABLE_RSPAMD=1`, an internal Redis instance is enabled implicitly.
-# This setting provides an opt-out to allow using an external instance instead.
-# 0 => Disabled
-# 1 => Enabled
-ENABLE_RSPAMD_REDIS=
-
-# When enabled,
-#
-# 1. the "[autolearning][rspamd-autolearn]" feature is turned on;
-# 2. the Bayes classifier will be trained when moving mails from or to the Junk folder (with the help of Sieve scripts).
-#
-# **0** => disabled
-# 1 => enabled
-RSPAMD_LEARN=1
-
-# This settings controls whether checks should be performed on emails coming
-# from authenticated users (i.e. most likely outgoing emails). The default value
-# is `0` in order to align better with SpamAssassin. We recommend reading
-# through https://rspamd.com/doc/tutorials/scanning_outbound.html though to
-# decide for yourself whether you need and want this feature.
-#
-# Note that DKIM signing of e-mails will still happen.
-RSPAMD_CHECK_AUTHENTICATED=0
-
-# Controls whether the Rspamd Greylisting module is enabled.
-# This module can further assist in avoiding spam emails by greylisting
-# e-mails with a certain spam score.
-#
-# **0** => disabled
-# 1 => enabled
-RSPAMD_GREYLISTING=0
-
-# Can be used to enable or disable the Hfilter group module.
-#
-# - 0 => Disabled
-# - **1** => Enabled
-RSPAMD_HFILTER=1
-
-# Can be used to control the score when the HFILTER_HOSTNAME_UNKNOWN symbol applies. A higher score is more punishing. Setting it to 15 is equivalent to rejecting the email when the check fails.
-#
-# Default: 6
-RSPAMD_HFILTER_HOSTNAME_UNKNOWN_SCORE=6
-
-# Can be used to enable or disable the (still experimental) neural module.
-#
-# - **0** => Disabled
-# - 1 => Enabled
-RSPAMD_NEURAL=0
-
-# Amavis content filter (used for ClamAV & SpamAssassin)
-# 0 => Disabled
-# 1 => Enabled
-ENABLE_AMAVIS=0
-
-# -1/-2/-3 => Only show errors
-# **0** => Show warnings
-# 1/2 => Show default informational output
-# 3/4/5 => log debug information (very verbose)
-AMAVIS_LOGLEVEL=0
-
-# This enables DNS block lists in Postscreen.
-# Note: Emails will be rejected, if they don't pass the block list checks!
-# **0** => DNS block lists are disabled
-# 1 => DNS block lists are enabled
-ENABLE_DNSBL=0
-
-# If you enable Fail2Ban, don't forget to add the following lines to your `compose.yaml`:
-# cap_add:
-# - NET_ADMIN
-# Otherwise, `nftables` won't be able to ban IPs.
-ENABLE_FAIL2BAN=0
-
-# Fail2Ban blocktype
-# drop => drop packet (send NO reply)
-# reject => reject packet (send ICMP unreachable)
-FAIL2BAN_BLOCKTYPE=drop
-
-# 1 => Enables Managesieve on port 4190
-# empty => disables Managesieve
-ENABLE_MANAGESIEVE=1
-
-# **enforce** => Allow other tests to complete. Reject attempts to deliver mail with a 550 SMTP reply, and log the helo/sender/recipient information. Repeat this test the next time the client connects.
-# drop => Drop the connection immediately with a 521 SMTP reply. Repeat this test the next time the client connects.
-# ignore => Ignore the failure of this test. Allow other tests to complete. Repeat this test the next time the client connects. This option is useful for testing and collecting statistics without blocking mail.
-POSTSCREEN_ACTION=enforce
-
-# empty => all daemons start
-# 1 => only launch postfix smtp
-SMTP_ONLY=
-
-# Please read [the SSL page in the documentation](https://docker-mailserver.github.io/docker-mailserver/latest/config/security/ssl) for more information.
-#
-# empty => SSL disabled
-# letsencrypt => Enables Let's Encrypt certificates
-# custom => Enables custom certificates
-# manual => Let's you manually specify locations of your SSL certificates for non-standard cases
-# self-signed => Enables self-signed certificates
-SSL_TYPE=letsencrypt
-
-# These are only supported with `SSL_TYPE=manual`.
-# Provide the path to your cert and key files that you've mounted access to within the container.
-SSL_CERT_PATH=
-SSL_KEY_PATH=
-# Optional: A 2nd certificate can be supported as fallback (dual cert support), eg ECDSA with an RSA fallback.
-# Useful for additional compatibility with older MTA and MUA (eg pre-2015).
-SSL_ALT_CERT_PATH=
-SSL_ALT_KEY_PATH=
-
-# Set how many days a virusmail will stay on the server before being deleted
-# empty => 7 days
-VIRUSMAILS_DELETE_DELAY=
-
-# Configure Postfix `virtual_transport` to deliver mail to a different LMTP client (default is a dovecot socket).
-# Provide any valid URI. Examples:
-#
-# empty => `lmtp:unix:/var/run/dovecot/lmtp` (default, configured in Postfix main.cf)
-# `lmtp:unix:private/dovecot-lmtp` (use socket)
-# `lmtps:inet:<host>:<port>` (secure lmtp with starttls)
-# `lmtp:<kopano-host>:2003` (use kopano as mailstore)
-POSTFIX_DAGENT=
-
-# Set the mailbox size limit for all users. If set to zero, the size will be unlimited (default). Size is in bytes.
-#
-# empty => 0
-POSTFIX_MAILBOX_SIZE_LIMIT=
-
-# See https://docker-mailserver.github.io/docker-mailserver/latest/config/account-management/overview/#quotas
-# 0 => Dovecot quota is disabled
-# 1 => Dovecot quota is enabled
-ENABLE_QUOTAS=1
-
-# Set the message size limit for all users. If set to zero, the size will be unlimited (not recommended!). Size is in bytes.
-#
-# empty => 10240000 (~10 MB)
-POSTFIX_MESSAGE_SIZE_LIMIT=
-
-# Mails larger than this limit won't be scanned.
-# ClamAV must be enabled (ENABLE_CLAMAV=1) for this.
-#
-# empty => 25M (25 MB)
-CLAMAV_MESSAGE_SIZE_LIMIT=
-
-# Enables regular pflogsumm mail reports.
-# This is a new option. The old REPORT options are still supported for backwards compatibility. If this is not set and reports are enabled with the old options, logrotate will be used.
-#
-# not set => No report
-# daily_cron => Daily report for the previous day
-# logrotate => Full report based on the mail log when it is rotated
-PFLOGSUMM_TRIGGER=
-
-# Recipient address for pflogsumm reports.
-#
-# not set => Use REPORT_RECIPIENT or POSTMASTER_ADDRESS
-# => Specify the recipient address(es)
-PFLOGSUMM_RECIPIENT=
-
-# Sender address (`FROM`) for pflogsumm reports if pflogsumm reports are enabled.
-#
-# not set => Use REPORT_SENDER
-# => Specify the sender address
-PFLOGSUMM_SENDER=
-
-# Interval for logwatch report.
-#
-# none => No report is generated
-# daily => Send a daily report
-# weekly => Send a report every week
-LOGWATCH_INTERVAL=
-
-# Recipient address for logwatch reports if they are enabled.
-#
-# not set => Use REPORT_RECIPIENT or POSTMASTER_ADDRESS
-# => Specify the recipient address(es)
-LOGWATCH_RECIPIENT=
-
-# Sender address (`FROM`) for logwatch reports if logwatch reports are enabled.
-#
-# not set => Use REPORT_SENDER
-# => Specify the sender address
-LOGWATCH_SENDER=
-
-# Defines who receives reports if they are enabled.
-# **empty** => ${POSTMASTER_ADDRESS}
-# => Specify the recipient address
-REPORT_RECIPIENT=
-
-# Defines who sends reports if they are enabled.
-# **empty** => mailserver-report@${DOMAINNAME}
-# => Specify the sender address
-REPORT_SENDER=
-
-# Changes the interval in which log files are rotated
-# **weekly** => Rotate log files weekly
-# daily => Rotate log files daily
-# monthly => Rotate log files monthly
-#
-# Note: This Variable actually controls logrotate inside the container
-# and rotates the log files depending on this setting. The main log output is
-# still available in its entirety via `docker logs mail` (Or your
-# respective container name). If you want to control logrotation for
-# the Docker-generated logfile see:
-# https://docs.docker.com/config/containers/logging/configure/
-#
-# Note: This variable can also determine the interval for Postfix's log summary reports, see [`PFLOGSUMM_TRIGGER`](#pflogsumm_trigger).
-LOGROTATE_INTERVAL=weekly
-
-# Defines how many log files are kept by logrorate
-LOGROTATE_COUNT=4
-
-
-# If enabled, employs `reject_unknown_client_hostname` to sender restrictions in Postfix's configuration.
-#
-# - **0** => Disabled
-# - 1 => Enabled
-POSTFIX_REJECT_UNKNOWN_CLIENT_HOSTNAME=0
-
-# Choose TCP/IP protocols for postfix to use
-# **all** => All possible protocols.
-# ipv4 => Use only IPv4 traffic. Most likely you want this behind Docker.
-# ipv6 => Use only IPv6 traffic.
-#
-# Note: More details at http://www.postfix.org/postconf.5.html#inet_protocols
-POSTFIX_INET_PROTOCOLS=all
-
-# Enables MTA-STS support for outbound mail.
-# More details: https://docker-mailserver.github.io/docker-mailserver/v13.3/config/best-practices/mta-sts/
-# - **0** ==> MTA-STS disabled
-# - 1 => MTA-STS enabled
-ENABLE_MTA_STS=0
-
-# Choose TCP/IP protocols for dovecot to use
-# **all** => Listen on all interfaces
-# ipv4 => Listen only on IPv4 interfaces. Most likely you want this behind Docker.
-# ipv6 => Listen only on IPv6 interfaces.
-#
-# Note: More information at https://dovecot.org/doc/dovecot-example.conf
-DOVECOT_INET_PROTOCOLS=all
-
-# -----------------------------------------------
-# --- SpamAssassin Section ----------------------
-# -----------------------------------------------
-
-ENABLE_SPAMASSASSIN=0
-
-# KAM is a 3rd party SpamAssassin ruleset, provided by the McGrail Foundation.
-# If SpamAssassin is enabled, KAM can be used in addition to the default ruleset.
-# - **0** => KAM disabled
-# - 1 => KAM enabled
-#
-# Note: only has an effect if `ENABLE_SPAMASSASSIN=1`
-ENABLE_SPAMASSASSIN_KAM=0
-
-# deliver spam messages to the inbox (tagged using SPAM_SUBJECT)
-SPAMASSASSIN_SPAM_TO_INBOX=1
-
-# spam messages will be moved in the Junk folder (SPAMASSASSIN_SPAM_TO_INBOX=1 required)
-MOVE_SPAM_TO_JUNK=1
-
-# spam messages will be marked as read
-MARK_SPAM_AS_READ=0
-
-# add 'spam info' headers at, or above this level
-SA_TAG=2.0
-
-# add 'spam detected' headers at, or above this level
-SA_TAG2=6.31
-
-# triggers spam evasive actions
-SA_KILL=10.0
-
-# -----------------------------------------------
-# --- Fetchmail Section -------------------------
-# -----------------------------------------------
-
-ENABLE_FETCHMAIL=0
-
-# The interval to fetch mail in seconds
-FETCHMAIL_POLL=300
-# Use multiple fetchmail instances (1 per poll entry in fetchmail.cf)
-# Supports multiple IMAP IDLE connections when a server is used across multiple poll entries
-# https://otremba.net/wiki/Fetchmail_(Debian)#Immediate_Download_via_IMAP_IDLE
-FETCHMAIL_PARALLEL=0
-
-# Enable or disable `getmail`.
-#
-# - **0** => Disabled
-# - 1 => Enabled
-ENABLE_GETMAIL=0
-
-# The number of minutes for the interval. Min: 1; Default: 5.
-GETMAIL_POLL=5
-
-# -----------------------------------------------
-# --- OAUTH2 Section ----------------------------
-# -----------------------------------------------
-
-# empty => OAUTH2 authentication is disabled
-# 1 => OAUTH2 authentication is enabled
-ENABLE_OAUTH2=
-
-# Specify the user info endpoint URL of the oauth2 provider
-# Example: https://oauth2.example.com/userinfo/
-OAUTH2_INTROSPECTION_URL=
-
-# -----------------------------------------------
-# --- LDAP Section ------------------------------
-# -----------------------------------------------
-
-# A second container for the ldap service is necessary (i.e. https://hub.docker.com/r/bitnami/openldap/)
-
-# empty => no
-# yes => LDAP over TLS enabled for Postfix
-LDAP_START_TLS=
-
-# empty => mail.example.com
-# Specify the `<dns-name>` / `<ip-address>` where the LDAP server is reachable via a URI like: `ldaps://mail.example.com`.
-# Note: You must include the desired URI scheme (`ldap://`, `ldaps://`, `ldapi://`).
-LDAP_SERVER_HOST=
-
-# empty => ou=people,dc=domain,dc=com
-# => e.g. LDAP_SEARCH_BASE=dc=mydomain,dc=local
-LDAP_SEARCH_BASE=
-
-# empty => cn=admin,dc=domain,dc=com
-# => take a look at examples of SASL_LDAP_BIND_DN
-LDAP_BIND_DN=
-
-# empty** => admin
-# => Specify the password to bind against ldap
-LDAP_BIND_PW=
-
-# e.g. `"(&(mail=%s)(mailEnabled=TRUE))"`
-# => Specify how ldap should be asked for users
-LDAP_QUERY_FILTER_USER=
-
-# e.g. `"(&(mailGroupMember=%s)(mailEnabled=TRUE))"`
-# => Specify how ldap should be asked for groups
-LDAP_QUERY_FILTER_GROUP=
-
-# e.g. `"(&(mailAlias=%s)(mailEnabled=TRUE))"`
-# => Specify how ldap should be asked for aliases
-LDAP_QUERY_FILTER_ALIAS=
-
-# e.g. `"(&(|(mail=*@%s)(mailalias=*@%s)(mailGroupMember=*@%s))(mailEnabled=TRUE))"`
-# => Specify how ldap should be asked for domains
-LDAP_QUERY_FILTER_DOMAIN=
-
-# -----------------------------------------------
-# --- Dovecot Section ---------------------------
-# -----------------------------------------------
-
-# empty => no
-# yes => LDAP over TLS enabled for Dovecot
-DOVECOT_TLS=
-
-# e.g. `"(&(objectClass=PostfixBookMailAccount)(uniqueIdentifier=%n))"`
-DOVECOT_USER_FILTER=
-
-# e.g. `"(&(objectClass=PostfixBookMailAccount)(uniqueIdentifier=%n))"`
-DOVECOT_PASS_FILTER=
-
-# Define the mailbox format to be used
-# default is maildir, supported values are: sdbox, mdbox, maildir
-DOVECOT_MAILBOX_FORMAT=maildir
-
-# empty => no
-# yes => Allow bind authentication for LDAP
-# https://doc.dovecot.org/2.4.0/core/config/auth/databases/ldap.html#authentication-bind
-DOVECOT_AUTH_BIND=
-
-# -----------------------------------------------
-# --- Postgrey Section --------------------------
-# -----------------------------------------------
-
-ENABLE_POSTGREY=0
-# greylist for N seconds
-POSTGREY_DELAY=300
-# delete entries older than N days since the last time that they have been seen
-POSTGREY_MAX_AGE=35
-# response when a mail is greylisted
-POSTGREY_TEXT="Delayed by Postgrey"
-# whitelist host after N successful deliveries (N=0 to disable whitelisting)
-POSTGREY_AUTO_WHITELIST_CLIENTS=5
-
-# -----------------------------------------------
-# --- SASL Section ------------------------------
-# -----------------------------------------------
-
-ENABLE_SASLAUTHD=0
-
-# empty => pam
-# `ldap` => authenticate against ldap server
-# `shadow` => authenticate against local user db
-# `mysql` => authenticate against mysql db
-# `rimap` => authenticate against imap server
-# Note: can be a list of mechanisms like pam ldap shadow
-SASLAUTHD_MECHANISMS=
-
-# empty => None
-# e.g. with SASLAUTHD_MECHANISMS rimap you need to specify the ip-address/servername of the imap server ==> xxx.xxx.xxx.xxx
-SASLAUTHD_MECH_OPTIONS=
-
-# empty => Use value of LDAP_SERVER_HOST
-# Note: You must include the desired URI scheme (`ldap://`, `ldaps://`, `ldapi://`).
-SASLAUTHD_LDAP_SERVER=
-
-# empty => Use value of LDAP_BIND_DN
-# specify an object with privileges to search the directory tree
-# e.g. active directory: SASLAUTHD_LDAP_BIND_DN=cn=Administrator,cn=Users,dc=mydomain,dc=net
-# e.g. openldap: SASLAUTHD_LDAP_BIND_DN=cn=admin,dc=mydomain,dc=net
-SASLAUTHD_LDAP_BIND_DN=
-
-# empty => Use value of LDAP_BIND_PW
-SASLAUTHD_LDAP_PASSWORD=
-
-# empty => Use value of LDAP_SEARCH_BASE
-# specify the search base
-SASLAUTHD_LDAP_SEARCH_BASE=
-
-# empty => default filter `(&(uniqueIdentifier=%u)(mailEnabled=TRUE))`
-# e.g. for active directory: `(&(sAMAccountName=%U)(objectClass=person))`
-# e.g. for openldap: `(&(uid=%U)(objectClass=person))`
-SASLAUTHD_LDAP_FILTER=
-
-# empty => no
-# yes => LDAP over TLS enabled for SASL
-# If set to yes, the protocol in SASLAUTHD_LDAP_SERVER must be ldap:// or missing.
-SASLAUTHD_LDAP_START_TLS=
-
-# empty => no
-# yes => Require and verify server certificate
-# If yes you must/could specify SASLAUTHD_LDAP_TLS_CACERT_FILE or SASLAUTHD_LDAP_TLS_CACERT_DIR.
-SASLAUTHD_LDAP_TLS_CHECK_PEER=
-
-# File containing CA (Certificate Authority) certificate(s).
-# empty => Nothing is added to the configuration
-# Any value => Fills the `ldap_tls_cacert_file` option
-SASLAUTHD_LDAP_TLS_CACERT_FILE=
-
-# Path to directory with CA (Certificate Authority) certificates.
-# empty => Nothing is added to the configuration
-# Any value => Fills the `ldap_tls_cacert_dir` option
-SASLAUTHD_LDAP_TLS_CACERT_DIR=
-
-# Specify what password attribute to use for password verification.
-# empty => Nothing is added to the configuration but the documentation says it is `userPassword` by default.
-# Any value => Fills the `ldap_password_attr` option
-SASLAUTHD_LDAP_PASSWORD_ATTR=
-
-# empty => `bind` will be used as a default value
-# `fastbind` => The fastbind method is used
-# `custom` => The custom method uses userPassword attribute to verify the password
-SASLAUTHD_LDAP_AUTH_METHOD=
-
-# Specify the authentication mechanism for SASL bind
-# empty => Nothing is added to the configuration
-# Any value => Fills the `ldap_mech` option
-SASLAUTHD_LDAP_MECH=
-
-# -----------------------------------------------
-# --- SRS Section -------------------------------
-# -----------------------------------------------
-
-# envelope_sender => Rewrite only envelope sender address (default)
-# header_sender => Rewrite only header sender (not recommended)
-# envelope_sender,header_sender => Rewrite both senders
-# An email has an "envelope" sender (indicating the sending server) and a
-# "header" sender (indicating who sent it). More strict SPF policies may require
-# you to replace both instead of just the envelope sender.
-SRS_SENDER_CLASSES=envelope_sender
-
-# empty => Envelope sender will be rewritten for all domains
-# provide comma separated list of domains to exclude from rewriting
-SRS_EXCLUDE_DOMAINS=
-
-# empty => generated when the image is built
-# provide a secret to use in base64
-# you may specify multiple keys, comma separated. the first one is used for
-# signing and the remaining will be used for verification. this is how you
-# rotate and expire keys
-SRS_SECRET=
-
-# -----------------------------------------------
-# --- Default Relay Host Section ----------------
-# -----------------------------------------------
-
-# Setup relaying all mail through a default relay host
-#
-# Set a default host to relay all mail through (optionally include a port)
-# Example: [mail.example.com]:587
-DEFAULT_RELAY_HOST=@@CRUPEST_MAIL_SERVER_RELAY@@
-
-# -----------------------------------------------
-# --- Multi-Domain Relay Section ----------------
-# -----------------------------------------------
-
-# Setup relaying for multiple domains based on the domain name of the sender
-# optionally uses usernames and passwords in postfix-sasl-password.cf and relay host mappings in postfix-relaymap.cf
-#
-# Set a default host to relay mail through
-# Example: mail.example.com
-RELAY_HOST=
-
-# empty => 25
-# default port to relay mail
-RELAY_PORT=@@CRUPEST_MAIL_SERVER_RELAY_PORT@@
-
-# -----------------------------------------------
-# --- Relay Host Credentials Section ------------
-# -----------------------------------------------
-
-# Configure a relay user and password to use with RELAY_HOST / DEFAULT_RELAY_HOST
-
-# empty => no default
-RELAY_USER=@@CRUPEST_MAIL_SERVER_RELAY_USER@@
-
-# empty => no default
-RELAY_PASSWORD=@@CRUPEST_MAIL_SERVER_RELAY_PASSWORD@@
diff --git a/store/home/config/mihomo/config.yaml b/store/home/config/mihomo/config.yaml
index 72d9ae7..cd2c3a3 100644
--- a/store/home/config/mihomo/config.yaml
+++ b/store/home/config/mihomo/config.yaml
@@ -5,7 +5,7 @@ external-controller: 127.0.0.1:9090
profile:
store-selected: true
-external-ui: /usr/share/metacubexd
+external-ui: ui/metacubexd
external-ui-name: metacubexd
external-ui-url: https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip
@@ -74,6 +74,8 @@ rules:
- DOMAIN-SUFFIX,ietf.org,node-select
- DOMAIN-SUFFIX,packagist.org,node-select
- DOMAIN-SUFFIX,metacubex.one,node-select
+ - DOMAIN-SUFFIX,winehq.org,node-select
+ - DOMAIN-SUFFIX,postfix.org,node-select
- MATCH,DIRECT
proxy-groups: