aboutsummaryrefslogtreecommitdiff
path: root/deno
diff options
context:
space:
mode:
Diffstat (limited to 'deno')
-rw-r--r--deno/deno.json6
-rw-r--r--deno/deno.lock1187
-rw-r--r--deno/mail-relay/.gitignore3
-rw-r--r--deno/mail-relay/app.ts81
-rw-r--r--deno/mail-relay/aws/app.ts136
-rw-r--r--deno/mail-relay/aws/context.ts41
-rw-r--r--deno/mail-relay/aws/deliver.ts114
-rw-r--r--deno/mail-relay/aws/retriever.ts100
-rw-r--r--deno/mail-relay/better-js.ts14
-rw-r--r--deno/mail-relay/config.ts103
-rw-r--r--deno/mail-relay/cron.ts43
-rw-r--r--deno/mail-relay/db.test.ts23
-rw-r--r--deno/mail-relay/db.ts145
-rw-r--r--deno/mail-relay/deno.json25
-rw-r--r--deno/mail-relay/dovecot/deliver.ts102
-rw-r--r--deno/mail-relay/dumb-smtp-server.ts119
-rw-r--r--deno/mail-relay/log.ts116
-rw-r--r--deno/mail-relay/mail.test.ts143
-rw-r--r--deno/mail-relay/mail.ts340
19 files changed, 2841 insertions, 0 deletions
diff --git a/deno/deno.json b/deno/deno.json
new file mode 100644
index 0000000..558795f
--- /dev/null
+++ b/deno/deno.json
@@ -0,0 +1,6 @@
+{
+ "workspace": [ "./mail-relay" ],
+ "tasks": {
+ "compile:mail-relay": "deno task --cwd=mail-relay compile"
+ }
+}
diff --git a/deno/deno.lock b/deno/deno.lock
new file mode 100644
index 0000000..020a2c4
--- /dev/null
+++ b/deno/deno.lock
@@ -0,0 +1,1187 @@
+{
+ "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.0.13": "1.0.13",
+ "jsr:@std/bytes@^1.0.5": "1.0.6",
+ "jsr:@std/cli@^1.0.19": "1.0.19",
+ "jsr:@std/csv@^1.0.6": "1.0.6",
+ "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/internal@^1.0.6": "1.0.7",
+ "jsr:@std/internal@^1.0.7": "1.0.7",
+ "jsr:@std/io@~0.225.2": "0.225.2",
+ "jsr:@std/path@0.217": "0.217.0",
+ "jsr:@std/path@1": "1.1.0",
+ "jsr:@std/path@^1.0.9": "1.1.0",
+ "jsr:@std/path@^1.1.0": "1.1.0",
+ "jsr:@std/streams@^1.0.9": "1.0.9",
+ "npm:@aws-sdk/client-s3@^3.821.0": "3.824.0",
+ "npm:@aws-sdk/client-sesv2@^3.821.0": "3.824.0",
+ "npm:@hono/zod-validator@0.7": "0.7.0_hono@4.7.11_zod@3.25.51",
+ "npm:@smithy/fetch-http-handler@^5.0.4": "5.0.4",
+ "npm:email-addresses@5": "5.0.0",
+ "npm:hono@^4.7.11": "4.7.11",
+ "npm:kysely@~0.28.2": "0.28.2",
+ "npm:zod@^3.25.48": "3.25.51"
+ },
+ "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",
+ "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/bytes@1.0.6": {
+ "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
+ },
+ "@std/cli@1.0.19": {
+ "integrity": "b3601a54891f89f3f738023af11960c4e6f7a45dc76cde39a6861124cba79e88"
+ },
+ "@std/csv@1.0.6": {
+ "integrity": "52ef0e62799a0028d278fa04762f17f9bd263fad9a8e7f98c14fbd371d62d9fd",
+ "dependencies": [
+ "jsr:@std/streams"
+ ]
+ },
+ "@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/io@0.225.2": {
+ "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7",
+ "dependencies": [
+ "jsr:@std/bytes"
+ ]
+ },
+ "@std/path@0.217.0": {
+ "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
+ "dependencies": [
+ "jsr:@std/assert@0.217"
+ ]
+ },
+ "@std/path@1.1.0": {
+ "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886"
+ },
+ "@std/streams@1.0.9": {
+ "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035",
+ "dependencies": [
+ "jsr:@std/bytes"
+ ]
+ }
+ },
+ "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.824.0": {
+ "integrity": "sha512-7neTQIdSVP/F4RTWG5T87LDpB955iQD6lxg9nJ00fdkIPczDcRtAEXow44NjF4fEdpQ1A9jokUtBSVE+GMXZ/A==",
+ "dependencies": [
+ "@aws-crypto/sha1-browser",
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core",
+ "@aws-sdk/credential-provider-node",
+ "@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",
+ "@aws-sdk/middleware-ssec",
+ "@aws-sdk/middleware-user-agent",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/signature-v4-multi-region",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node",
+ "@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.824.0": {
+ "integrity": "sha512-WRssgE34ZTO0It5knqDsjp42CwyAC0RnPLHI1f8lOZIpAZjtTUFWuptKriDoycDXaGPkRcu1dcvrYqUskBXn1A==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core",
+ "@aws-sdk/credential-provider-node",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/signature-v4-multi-region",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node",
+ "@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.823.0": {
+ "integrity": "sha512-dBWdsbyGw8rPfdCsZySNtTOGQK4EZ8lxB/CneSQWRBPHgQ+Ys88NXxImO8xfWO7Itt1eh8O7UDTZ9+smcvw2pw==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node",
+ "@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.823.0": {
+ "integrity": "sha512-1Cf4w8J7wYexz0KU3zpaikHvldGXQEjFldHOhm0SBGRy7qfYNXecfJAamccF7RdgLxKGgkv5Pl9zX/Z/DcW9zg==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@aws-sdk/xml-builder",
+ "@smithy/core",
+ "@smithy/node-config-provider",
+ "@smithy/property-provider",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-middleware",
+ "@smithy/util-utf8@4.0.0",
+ "fast-xml-parser",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-env@3.823.0": {
+ "integrity": "sha512-AIrLLwumObge+U1klN4j5ToIozI+gE9NosENRyHe0GIIZgTLOG/8jxrMFVYFeNHs7RUtjDTxxewislhFyGxJ/w==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-http@3.823.0": {
+ "integrity": "sha512-u4DXvB/J/o2bcvP1JP6n3ch7V3/NngmiJFPsM0hKUyRlLuWM37HEDEdjPRs3/uL/soTxrEhWKTA9//YVkvzI0w==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@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.823.0": {
+ "integrity": "sha512-C0o63qviK5yFvjH9zKWAnCUBkssJoQ1A1XAHe0IAQkurzoNBSmu9oVemqwnKKHA4H6QrmusaEERfL00yohIkJA==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/credential-provider-env",
+ "@aws-sdk/credential-provider-http",
+ "@aws-sdk/credential-provider-process",
+ "@aws-sdk/credential-provider-sso",
+ "@aws-sdk/credential-provider-web-identity",
+ "@aws-sdk/nested-clients",
+ "@aws-sdk/types",
+ "@smithy/credential-provider-imds",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-node@3.823.0": {
+ "integrity": "sha512-nfSxXVuZ+2GJDpVFlflNfh55Yb4BtDsXLGNssXF5YU6UgSPsi8j2YkaE92Jv2s7dlUK07l0vRpLyPuXMaGeiRQ==",
+ "dependencies": [
+ "@aws-sdk/credential-provider-env",
+ "@aws-sdk/credential-provider-http",
+ "@aws-sdk/credential-provider-ini",
+ "@aws-sdk/credential-provider-process",
+ "@aws-sdk/credential-provider-sso",
+ "@aws-sdk/credential-provider-web-identity",
+ "@aws-sdk/types",
+ "@smithy/credential-provider-imds",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-process@3.823.0": {
+ "integrity": "sha512-U/A10/7zu2FbMFFVpIw95y0TZf+oYyrhZTBn9eL8zgWcrYRqxrxdqtPj/zMrfIfyIvQUhuJSENN4dx4tfpCMWQ==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-sso@3.823.0": {
+ "integrity": "sha512-ff8IM80Wqz1V7VVMaMUqO2iR417jggfGWLPl8j2l7uCgwpEyop1ZZl5CFVYEwSupRBtwp+VlW1gTCk7ke56MUw==",
+ "dependencies": [
+ "@aws-sdk/client-sso",
+ "@aws-sdk/core",
+ "@aws-sdk/token-providers",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/credential-provider-web-identity@3.823.0": {
+ "integrity": "sha512-lzoZdJMQq9w7i4lXVka30cVBe/dZoUDZST8Xz/soEd73gg7RTKgG+0szL4xFWgdBDgcJDWLfZfJzlbyIVyAyOA==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/nested-clients",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-bucket-endpoint@3.821.0": {
+ "integrity": "sha512-cebgeytKlWOgGczLo3BPvNY9XlzAzGZQANSysgJ2/8PSldmUpXRIF+GKPXDVhXeInWYHIfB8zZi3RqrPoXcNYQ==",
+ "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.821.0": {
+ "integrity": "sha512-zAOoSZKe1njOrtynvK6ZORU57YGv5I7KP4+rwOvUN3ZhJbQ7QPf8gKtFUCYAPRMegaXCKF/ADPtDZBAmM+zZ9g==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-flexible-checksums@3.823.0": {
+ "integrity": "sha512-Elt6G1ryEEdkrppqbyJON0o2x4x9xKknimJtMLdfG1b4YfO9X+UB31pk4R2SHvMYfrJ+p8DE2jRAhvV4g/dwIQ==",
+ "dependencies": [
+ "@aws-crypto/crc32",
+ "@aws-crypto/crc32c",
+ "@aws-crypto/util",
+ "@aws-sdk/core",
+ "@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.821.0": {
+ "integrity": "sha512-xSMR+sopSeWGx5/4pAGhhfMvGBHioVBbqGvDs6pG64xfNwM5vq5s5v6D04e2i+uSTj4qGa71dLUs5I0UzAK3sw==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-location-constraint@3.821.0": {
+ "integrity": "sha512-sKrm80k0t3R0on8aA/WhWFoMaAl4yvdk+riotmMElLUpcMcRXAd1+600uFVrxJqZdbrKQ0mjX0PjT68DlkYXLg==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-logger@3.821.0": {
+ "integrity": "sha512-0cvI0ipf2tGx7fXYEEN5fBeZDz2RnHyb9xftSgUsEq7NBxjV0yTZfLJw6Za5rjE6snC80dRN8+bTNR1tuG89zA==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-recursion-detection@3.821.0": {
+ "integrity": "sha512-efmaifbhBoqKG3bAoEfDdcM8hn1psF+4qa7ykWuYmfmah59JBeqHLfz5W9m9JoTwoKPkFcVLWZxnyZzAnVBOIg==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-sdk-s3@3.823.0": {
+ "integrity": "sha512-UV755wt2HDru8PbxLn2S0Fvwgdn9mYamexn31Q6wyUGQ6rkpjKNEzL+oNDGQQmDQAOcQO+nLubKFsCwtBM02fQ==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/types",
+ "@aws-sdk/util-arn-parser",
+ "@smithy/core",
+ "@smithy/node-config-provider",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4",
+ "@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.821.0": {
+ "integrity": "sha512-YYi1Hhr2AYiU/24cQc8HIB+SWbQo6FBkMYojVuz/zgrtkFmALxENGF/21OPg7f/QWd+eadZJRxCjmRwh5F2Cxg==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/middleware-user-agent@3.823.0": {
+ "integrity": "sha512-TKRQK09ld1LrIPExC9rIDpqnMsWcv+eq8ABKFHVo8mDLTSuWx/IiQ4eCh9T5zDuEZcLY4nNYCSzXKqw6XKcMCA==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints",
+ "@smithy/core",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/nested-clients@3.823.0": {
+ "integrity": "sha512-/BcyOBubrJnd2gxlbbmNJR1w0Z3OVN/UE8Yz20e+ou+Mijjv7EbtVwmWvio1e3ZjphwdA8tVfPYZKwXmrvHKmQ==",
+ "dependencies": [
+ "@aws-crypto/sha256-browser",
+ "@aws-crypto/sha256-js",
+ "@aws-sdk/core",
+ "@aws-sdk/middleware-host-header",
+ "@aws-sdk/middleware-logger",
+ "@aws-sdk/middleware-recursion-detection",
+ "@aws-sdk/middleware-user-agent",
+ "@aws-sdk/region-config-resolver",
+ "@aws-sdk/types",
+ "@aws-sdk/util-endpoints",
+ "@aws-sdk/util-user-agent-browser",
+ "@aws-sdk/util-user-agent-node",
+ "@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.821.0": {
+ "integrity": "sha512-t8og+lRCIIy5nlId0bScNpCkif8sc0LhmtaKsbm0ZPm3sCa/WhCbSZibjbZ28FNjVCV+p0D9RYZx0VDDbtWyjw==",
+ "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.824.0": {
+ "integrity": "sha512-HBjuWeN6Z1pvJjUvGXdMNLwEypKKB4km6zXj9jsbOOwP8NTL6J5rY+JmlX/mfBTmvzmI0kMu2bxlQ4ME2CIRbA==",
+ "dependencies": [
+ "@aws-sdk/middleware-sdk-s3",
+ "@aws-sdk/types",
+ "@smithy/protocol-http",
+ "@smithy/signature-v4",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/token-providers@3.823.0": {
+ "integrity": "sha512-vz6onCb/+g4y+owxGGPMEMdN789dTfBOgz/c9pFv0f01840w9Rrt46l+gjQlnXnx+0KG6wNeBIVhFdbCfV3HyQ==",
+ "dependencies": [
+ "@aws-sdk/core",
+ "@aws-sdk/nested-clients",
+ "@aws-sdk/types",
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/types@3.821.0": {
+ "integrity": "sha512-Znroqdai1a90TlxGaJ+FK1lwC0fHpo97Xjsp5UKGR5JODYm7f9+/fF17ebO1KdoBr/Rm0UIFiF5VmI8ts9F1eA==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-arn-parser@3.804.0": {
+ "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-endpoints@3.821.0": {
+ "integrity": "sha512-Uknt/zUZnLE76zaAAPEayOeF5/4IZ2puTFXvcSCWHsi9m3tqbb9UozlnlVqvCZLCRWfQryZQoG2W4XSS3qgk5A==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "@smithy/util-endpoints",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-locate-window@3.804.0": {
+ "integrity": "sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-user-agent-browser@3.821.0": {
+ "integrity": "sha512-irWZHyM0Jr1xhC+38OuZ7JB6OXMLPZlj48thElpsO1ZSLRkLZx5+I7VV6k3sp2yZ7BYbKz/G2ojSv4wdm7XTLw==",
+ "dependencies": [
+ "@aws-sdk/types",
+ "@smithy/types",
+ "bowser",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/util-user-agent-node@3.823.0": {
+ "integrity": "sha512-WvNeRz7HV3JLBVGTXW4Qr5QvvWY0vtggH5jW/NqHFH+ZEliVQaUIJ/HNLMpMoCSiu/DlpQAyAjRZXAptJ0oqbw==",
+ "dependencies": [
+ "@aws-sdk/middleware-user-agent",
+ "@aws-sdk/types",
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@aws-sdk/xml-builder@3.821.0": {
+ "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@hono/zod-validator@0.7.0_hono@4.7.11_zod@3.25.51": {
+ "integrity": "sha512-qe2ZE6sHFE98dcUrbYMtS3bAV8hqcCOflykvZga2S7XhmNSZzT+dIz4OuMILsjLHkJw9JMn912/dB7dQOmuPvg==",
+ "dependencies": [
+ "hono",
+ "zod"
+ ]
+ },
+ "@smithy/abort-controller@4.0.4": {
+ "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==",
+ "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.4": {
+ "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==",
+ "dependencies": [
+ "@smithy/node-config-provider",
+ "@smithy/types",
+ "@smithy/util-config-provider",
+ "@smithy/util-middleware",
+ "tslib"
+ ]
+ },
+ "@smithy/core@3.5.3": {
+ "integrity": "sha512-xa5byV9fEguZNofCclv6v9ra0FYh5FATQW/da7FQUVTic94DfrN/NvmKZjrMyzbpqfot9ZjBaO8U1UeTbmSLuA==",
+ "dependencies": [
+ "@smithy/middleware-serde",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-base64",
+ "@smithy/util-body-length-browser",
+ "@smithy/util-middleware",
+ "@smithy/util-stream",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/credential-provider-imds@4.0.6": {
+ "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==",
+ "dependencies": [
+ "@smithy/node-config-provider",
+ "@smithy/property-provider",
+ "@smithy/types",
+ "@smithy/url-parser",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-codec@4.0.4": {
+ "integrity": "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig==",
+ "dependencies": [
+ "@aws-crypto/crc32",
+ "@smithy/types",
+ "@smithy/util-hex-encoding",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-browser@4.0.4": {
+ "integrity": "sha512-3fb/9SYaYqbpy/z/H3yIi0bYKyAa89y6xPmIqwr2vQiUT2St+avRt8UKwsWt9fEdEasc5d/V+QjrviRaX1JRFA==",
+ "dependencies": [
+ "@smithy/eventstream-serde-universal",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-config-resolver@4.1.2": {
+ "integrity": "sha512-JGtambizrWP50xHgbzZI04IWU7LdI0nh/wGbqH3sJesYToMi2j/DcoElqyOcqEIG/D4tNyxgRuaqBXWE3zOFhQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-node@4.0.4": {
+ "integrity": "sha512-RD6UwNZ5zISpOWPuhVgRz60GkSIp0dy1fuZmj4RYmqLVRtejFqQ16WmfYDdoSoAjlp1LX+FnZo+/hkdmyyGZ1w==",
+ "dependencies": [
+ "@smithy/eventstream-serde-universal",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/eventstream-serde-universal@4.0.4": {
+ "integrity": "sha512-UeJpOmLGhq1SLox79QWw/0n2PFX+oPRE1ZyRMxPIaFEfCqWaqpB7BU9C8kpPOGEhLF7AwEqfFbtwNxGy4ReENA==",
+ "dependencies": [
+ "@smithy/eventstream-codec",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/fetch-http-handler@5.0.4": {
+ "integrity": "sha512-AMtBR5pHppYMVD7z7G+OlHHAcgAN7v0kVKEpHuTO4Gb199Gowh0taYi9oDStFeUhetkeP55JLSVlTW1n9rFtUw==",
+ "dependencies": [
+ "@smithy/protocol-http",
+ "@smithy/querystring-builder",
+ "@smithy/types",
+ "@smithy/util-base64",
+ "tslib"
+ ]
+ },
+ "@smithy/hash-blob-browser@4.0.4": {
+ "integrity": "sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==",
+ "dependencies": [
+ "@smithy/chunked-blob-reader",
+ "@smithy/chunked-blob-reader-native",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/hash-node@4.0.4": {
+ "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-buffer-from@4.0.0",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/hash-stream-node@4.0.4": {
+ "integrity": "sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/invalid-dependency@4.0.4": {
+ "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==",
+ "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.4": {
+ "integrity": "sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-utf8@4.0.0",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-content-length@4.0.4": {
+ "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==",
+ "dependencies": [
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-endpoint@4.1.11": {
+ "integrity": "sha512-zDogwtRLzKl58lVS8wPcARevFZNBOOqnmzWWxVe9XiaXU2CADFjvJ9XfNibgkOWs08sxLuSr81NrpY4mgp9OwQ==",
+ "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.12": {
+ "integrity": "sha512-wvIH70c4e91NtRxdaLZF+mbLZ/HcC6yg7ySKUiufL6ESp6zJUSnJucZ309AvG9nqCFHSRB5I6T3Ez1Q9wCh0Ww==",
+ "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.8": {
+ "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==",
+ "dependencies": [
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/middleware-stack@4.0.4": {
+ "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/node-config-provider@4.1.3": {
+ "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==",
+ "dependencies": [
+ "@smithy/property-provider",
+ "@smithy/shared-ini-file-loader",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/node-http-handler@4.0.6": {
+ "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==",
+ "dependencies": [
+ "@smithy/abort-controller",
+ "@smithy/protocol-http",
+ "@smithy/querystring-builder",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/property-provider@4.0.4": {
+ "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/protocol-http@5.1.2": {
+ "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/querystring-builder@4.0.4": {
+ "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==",
+ "dependencies": [
+ "@smithy/types",
+ "@smithy/util-uri-escape",
+ "tslib"
+ ]
+ },
+ "@smithy/querystring-parser@4.0.4": {
+ "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/service-error-classification@4.0.5": {
+ "integrity": "sha512-LvcfhrnCBvCmTee81pRlh1F39yTS/+kYleVeLCwNtkY8wtGg8V/ca9rbZZvYIl8OjlMtL6KIjaiL/lgVqHD2nA==",
+ "dependencies": [
+ "@smithy/types"
+ ]
+ },
+ "@smithy/shared-ini-file-loader@4.0.4": {
+ "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/signature-v4@5.1.2": {
+ "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==",
+ "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.4.3": {
+ "integrity": "sha512-xxzNYgA0HD6ETCe5QJubsxP0hQH3QK3kbpJz3QrosBCuIWyEXLR/CO5hFb2OeawEKUxMNhz3a1nuJNN2np2RMA==",
+ "dependencies": [
+ "@smithy/core",
+ "@smithy/middleware-endpoint",
+ "@smithy/middleware-stack",
+ "@smithy/protocol-http",
+ "@smithy/types",
+ "@smithy/util-stream",
+ "tslib"
+ ]
+ },
+ "@smithy/types@4.3.1": {
+ "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
+ "@smithy/url-parser@4.0.4": {
+ "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==",
+ "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.19": {
+ "integrity": "sha512-mvLMh87xSmQrV5XqnUYEPoiFFeEGYeAKIDDKdhE2ahqitm8OHM3aSvhqL6rrK6wm1brIk90JhxDf5lf2hbrLbQ==",
+ "dependencies": [
+ "@smithy/property-provider",
+ "@smithy/smithy-client",
+ "@smithy/types",
+ "bowser",
+ "tslib"
+ ]
+ },
+ "@smithy/util-defaults-mode-node@4.0.19": {
+ "integrity": "sha512-8tYnx+LUfj6m+zkUUIrIQJxPM1xVxfRBvoGHua7R/i6qAxOMjqR6CpEpDwKoIs1o0+hOjGvkKE23CafKL0vJ9w==",
+ "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.6": {
+ "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==",
+ "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.4": {
+ "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==",
+ "dependencies": [
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-retry@4.0.5": {
+ "integrity": "sha512-V7MSjVDTlEt/plmOFBn1762Dyu5uqMrV2Pl2X0dYk4XvWfdWJNe9Bs5Bzb56wkCuiWjSfClVMGcsuKrGj7S/yg==",
+ "dependencies": [
+ "@smithy/service-error-classification",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "@smithy/util-stream@4.2.2": {
+ "integrity": "sha512-aI+GLi7MJoVxg24/3J1ipwLoYzgkB4kUfogZfnslcYlynj3xsQ0e7vk4TnTro9hhsS5PvX1mwmkRqqHQjwcU7w==",
+ "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.5": {
+ "integrity": "sha512-4QvC49HTteI1gfemu0I1syWovJgPvGn7CVUoN9ZFkdvr/cCFkrEL7qNCdx/2eICqDWEGnnr68oMdSIPCLAriSQ==",
+ "dependencies": [
+ "@smithy/abort-controller",
+ "@smithy/types",
+ "tslib"
+ ]
+ },
+ "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.11": {
+ "integrity": "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="
+ },
+ "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=="
+ },
+ "uuid@9.0.1": {
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "bin": true
+ },
+ "zod@3.25.51": {
+ "integrity": "sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg=="
+ }
+ },
+ "workspace": {
+ "members": {
+ "mail-relay": {
+ "dependencies": [
+ "jsr:@db/sqlite@0.12",
+ "jsr:@std/cli@^1.0.19",
+ "jsr:@std/csv@^1.0.6",
+ "jsr:@std/encoding@^1.0.10",
+ "jsr:@std/expect@^1.0.16",
+ "jsr:@std/io@~0.225.2",
+ "jsr:@std/path@^1.1.0",
+ "jsr:@std/testing@^1.0.13",
+ "npm:@aws-sdk/client-s3@^3.821.0",
+ "npm:@aws-sdk/client-sesv2@^3.821.0",
+ "npm:@hono/zod-validator@0.7",
+ "npm:@smithy/fetch-http-handler@^5.0.4",
+ "npm:email-addresses@5",
+ "npm:hono@^4.7.11",
+ "npm:kysely@~0.28.2",
+ "npm:zod@^3.25.48"
+ ]
+ }
+ }
+ }
+}
diff --git a/deno/mail-relay/.gitignore b/deno/mail-relay/.gitignore
new file mode 100644
index 0000000..327aef0
--- /dev/null
+++ b/deno/mail-relay/.gitignore
@@ -0,0 +1,3 @@
+out
+.env.local
+db.sqlite
diff --git a/deno/mail-relay/app.ts b/deno/mail-relay/app.ts
new file mode 100644
index 0000000..deb72c2
--- /dev/null
+++ b/deno/mail-relay/app.ts
@@ -0,0 +1,81 @@
+import { join } from "@std/path";
+import { Hono } from "hono";
+import { logger as honoLogger } from "hono/logger";
+
+import log from "./log.ts";
+import config from "./config.ts";
+import { DbService } from "./db.ts";
+import {
+ AliasRecipientMailHook,
+ FallbackRecipientHook,
+ MailDeliverer,
+} from "./mail.ts";
+import { DovecotMailDeliverer } from "./dovecot/deliver.ts";
+import { CronTask, CronTaskConfig } from "./cron.ts";
+import { DumbSmtpServer } from "./dumb-smtp-server.ts";
+
+export abstract class AppBase {
+ protected readonly db: DbService;
+ protected readonly crons: CronTask[] = [];
+ protected readonly routes: Hono[] = [];
+ protected readonly inboundDeliverer: MailDeliverer;
+ protected readonly hono = new Hono();
+
+ protected abstract readonly outboundDeliverer: MailDeliverer;
+
+ constructor() {
+ const dataPath = config.get("dataPath");
+ Deno.mkdirSync(dataPath, { recursive: true });
+ log.path = join(dataPath, "log");
+ log.info(config);
+
+ this.db = new DbService(join(dataPath, "db.sqlite"));
+ this.inboundDeliverer = new DovecotMailDeliverer();
+ this.inboundDeliverer.preHooks.push(
+ new FallbackRecipientHook(new Set(config.getList("inboundFallback"))),
+ new AliasRecipientMailHook(join(dataPath, "aliases.csv")),
+ );
+
+ this.hono.onError((err, c) => {
+ log.error(err);
+ return c.json({ msg: "Server error, check its log." }, 500);
+ });
+
+ this.hono.use(honoLogger());
+ this.hono.post("/send/raw", async (context) => {
+ const body = await context.req.text();
+ if (body.trim().length === 0) {
+ return context.json({ msg: "Can't send an empty mail." }, 400);
+ } else {
+ const result = await this.outboundDeliverer.deliverRaw(body);
+ return context.json({
+ awsMessageId: result.awsMessageId,
+ });
+ }
+ });
+ this.hono.post("/receive/raw", async (context) => {
+ await this.inboundDeliverer.deliverRaw(await context.req.text());
+ return context.json({ "msg": "Done!" });
+ });
+ }
+
+ createCron(config: CronTaskConfig): CronTask {
+ const cron = new CronTask(config);
+ this.crons.push(cron);
+ return cron;
+ }
+
+ async setup() {
+ await this.db.migrate()
+ }
+
+ serve(): { smtp: DumbSmtpServer; http: Deno.HttpServer } {
+ const smtp = new DumbSmtpServer(this.outboundDeliverer);
+ smtp.serve();
+ const http = Deno.serve({
+ hostname: config.HTTP_HOST,
+ port: config.HTTP_PORT,
+ }, this.hono.fetch);
+ return { smtp, http };
+ }
+}
diff --git a/deno/mail-relay/aws/app.ts b/deno/mail-relay/aws/app.ts
new file mode 100644
index 0000000..1fda64e
--- /dev/null
+++ b/deno/mail-relay/aws/app.ts
@@ -0,0 +1,136 @@
+import { parseArgs } from "@std/cli";
+import { z } from "zod";
+import { zValidator } from "@hono/zod-validator";
+
+import log from "../log.ts";
+import config from "../config.ts";
+import { AppBase } from "../app.ts";
+import { AwsContext } from "./context.ts";
+import {
+ AwsMailDeliverer,
+ AwsMailMessageIdRewriteHook,
+ AwsMailMessageIdSaveHook,
+} from "./deliver.ts";
+import { AwsMailRetriever } from "./retriever.ts";
+
+export class AwsRelayApp extends AppBase {
+ readonly #aws = new AwsContext();
+ readonly #retriever;
+ protected readonly outboundDeliverer = new AwsMailDeliverer(this.#aws);
+
+ constructor() {
+ super();
+ this.#retriever = new AwsMailRetriever(this.#aws, this.inboundDeliverer);
+
+ this.outboundDeliverer.preHooks.push(
+ new AwsMailMessageIdRewriteHook(this.db),
+ );
+ this.outboundDeliverer.postHooks.push(
+ new AwsMailMessageIdSaveHook(this.db),
+ );
+
+ this.hono.post(
+ `/${config.get("awsInboundPath")}`,
+ async (ctx, next) => {
+ const auth = ctx.req.header("Authorization");
+ if (auth !== config.get("awsInboundKey")) {
+ return ctx.json({ "msg": "Bad auth!" }, 403);
+ }
+ await next();
+ },
+ zValidator(
+ "json",
+ z.object({
+ key: z.string(),
+ recipients: z.optional(z.array(z.string())),
+ }),
+ ),
+ async (ctx) => {
+ const { key, recipients } = ctx.req.valid("json");
+ await this.#retriever.deliverS3Mail(key, recipients);
+ return ctx.json({ "msg": "Done!" });
+ },
+ );
+ }
+
+ realServe() {
+ this.createCron({
+ name: "live-mail-recycler",
+ interval: 6 * 3600 * 1000,
+ callback: () => {
+ return this.#retriever.recycleLiveMails();
+ },
+ startNow: true,
+ });
+
+ return this.serve();
+ }
+
+ readonly cli = {
+ "init": (_: unknown) => {
+ log.info("Just init!");
+ return Promise.resolve();
+ },
+ "list-lives": async (_: unknown) => {
+ const liveMails = await this.#retriever.listLiveMails();
+ log.info(`Total ${liveMails.length}:`);
+ log.info(liveMails.join("\n"));
+ },
+ "recycle-lives": async (_: unknown) => {
+ await this.#retriever.recycleLiveMails();
+ },
+ "serve": async (_: unknown) => {
+ await this.serve().http.finished;
+ },
+ "real-serve": async (_: unknown) => {
+ await this.realServe().http.finished;
+ },
+ } as const;
+}
+
+const nonServerCli = {
+ "sendmail": async (_: unknown) => {
+ const decoder = new TextDecoder();
+ let text = "";
+ for await (const chunk of Deno.stdin.readable) {
+ text += decoder.decode(chunk);
+ }
+
+ const res = await fetch(
+ `http://localhost:${config.HTTP_PORT}/send/raw`,
+ {
+ method: "post",
+ body: text,
+ },
+ );
+ log.infoOrError(!res.ok, res);
+ log.infoOrError(!res.ok, "Body\n" + await res.text());
+ if (!res.ok) Deno.exit(-1);
+ },
+} as const;
+
+if (import.meta.main) {
+ const args = parseArgs(Deno.args);
+
+ if (args._.length === 0) {
+ throw new Error("You must specify a command.");
+ }
+
+ const command = args._[0];
+
+ if (command in nonServerCli) {
+ log.info(`Run non-server command ${command}.`);
+ await nonServerCli[command as keyof typeof nonServerCli](args);
+ Deno.exit(0);
+ }
+
+ const app = new AwsRelayApp();
+ await app.setup();
+ if (command in app.cli) {
+ log.info(`Run command ${command}.`);
+ await app.cli[command as keyof AwsRelayApp["cli"]](args);
+ Deno.exit(0);
+ } else {
+ throw new Error(command + " is not a valid command.");
+ }
+}
diff --git a/deno/mail-relay/aws/context.ts b/deno/mail-relay/aws/context.ts
new file mode 100644
index 0000000..b1e0336
--- /dev/null
+++ b/deno/mail-relay/aws/context.ts
@@ -0,0 +1,41 @@
+import {
+ CopyObjectCommand,
+ DeleteObjectCommand,
+ S3Client,
+} from "@aws-sdk/client-s3";
+import { FetchHttpHandler } from "@smithy/fetch-http-handler";
+
+import config from "../config.ts";
+
+export class AwsContext {
+ readonly credentials = () =>
+ Promise.resolve({
+ accessKeyId: config.get("awsUser"),
+ secretAccessKey: config.get("awsPassword"),
+ });
+ readonly requestHandler = new FetchHttpHandler();
+
+ get region() {
+ return config.get("awsRegion");
+ }
+}
+
+export async function s3MoveObject(
+ client: S3Client,
+ bucket: string,
+ path: string,
+ newPath: string,
+): Promise<void> {
+ const copyCommand = new CopyObjectCommand({
+ Bucket: bucket,
+ Key: newPath,
+ CopySource: `${bucket}/${path}`,
+ });
+ await client.send(copyCommand);
+
+ const deleteCommand = new DeleteObjectCommand({
+ Bucket: bucket,
+ Key: path,
+ });
+ await client.send(deleteCommand);
+}
diff --git a/deno/mail-relay/aws/deliver.ts b/deno/mail-relay/aws/deliver.ts
new file mode 100644
index 0000000..0db5fa8
--- /dev/null
+++ b/deno/mail-relay/aws/deliver.ts
@@ -0,0 +1,114 @@
+// spellchecker: words sesv2 amazonses
+
+import { SendEmailCommand, SESv2Client } from "@aws-sdk/client-sesv2";
+
+import log from "../log.ts";
+import { DbService } from "../db.ts";
+import {
+ Mail,
+ MailDeliverContext,
+ MailDeliverHook,
+ SyncMailDeliverer,
+} from "../mail.ts";
+import { AwsContext } from "./context.ts";
+
+declare module "../mail.ts" {
+ interface MailDeliverResult {
+ awsMessageId?: string;
+ }
+}
+
+export class AwsMailMessageIdRewriteHook implements MailDeliverHook {
+ readonly #db;
+
+ constructor(db: DbService) {
+ this.#db = db;
+ }
+
+ async callback(context: MailDeliverContext): Promise<void> {
+ log.info("Rewrite message ids...");
+ const addresses = context.mail.simpleFindAllAddresses();
+ log.info(`Addresses found in mail: ${addresses.join(", ")}.`);
+ for (const address of addresses) {
+ const awsMessageId = await this.#db.messageIdToAws(address);
+ if (awsMessageId != null && awsMessageId.length !== 0) {
+ log.info(`Rewrite ${address} to ${awsMessageId}.`);
+ context.mail.raw = context.mail.raw.replaceAll(address, awsMessageId);
+ }
+ }
+ log.info("Done rewrite message ids.");
+ }
+}
+
+export class AwsMailMessageIdSaveHook implements MailDeliverHook {
+ readonly #db;
+
+ constructor(db: DbService) {
+ this.#db = db;
+ }
+
+ async callback(context: MailDeliverContext): Promise<void> {
+ log.info("Save aws message ids...");
+ const messageId = context.mail.startSimpleParse().sections().headers()
+ .messageId();
+ if (messageId == null) {
+ log.info("Original mail does not have message id. Skip saving.");
+ return;
+ }
+ if (context.result.awsMessageId != null) {
+ log.info(`Saving ${messageId} => ${context.result.awsMessageId}.`);
+ await this.#db.addMessageIdMap({
+ message_id: messageId,
+ aws_message_id: context.result.awsMessageId,
+ });
+ }
+ log.info("Done save message ids.");
+ }
+}
+
+export class AwsMailDeliverer extends SyncMailDeliverer {
+ readonly name = "aws";
+ readonly #aws;
+ readonly #ses;
+
+ constructor(aws: AwsContext) {
+ super();
+ this.#aws = aws;
+ this.#ses = new SESv2Client(aws);
+ }
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ log.info("Begin to call aws send-email api...");
+
+ try {
+ const sendCommand = new SendEmailCommand({
+ Content: {
+ Raw: { Data: mail.toUtf8Bytes() },
+ },
+ });
+
+ const res = await this.#ses.send(sendCommand);
+ if (res.MessageId == null) {
+ log.warn("Aws send-email returns no message id.");
+ } else {
+ context.result.awsMessageId =
+ `${res.MessageId}@${this.#aws.region}.amazonses.com`;
+ }
+
+ context.result.recipients.set("*", {
+ kind: "done",
+ message:
+ `Successfully called aws send-email, message id ${context.result.awsMessageId}.`,
+ });
+ } catch (cause) {
+ context.result.recipients.set("*", {
+ kind: "fail",
+ message: "An error was thrown when calling aws send-email." + cause,
+ cause,
+ });
+ }
+ }
+}
diff --git a/deno/mail-relay/aws/retriever.ts b/deno/mail-relay/aws/retriever.ts
new file mode 100644
index 0000000..756cfc3
--- /dev/null
+++ b/deno/mail-relay/aws/retriever.ts
@@ -0,0 +1,100 @@
+/// <reference types="npm:@types/node" />
+
+import {
+ GetObjectCommand,
+ ListObjectsV2Command,
+ S3Client,
+} from "@aws-sdk/client-s3";
+
+import log from "../log.ts";
+import config from "../config.ts";
+import "../better-js.ts";
+
+import { Mail, MailDeliverer } from "../mail.ts";
+import { AwsContext, s3MoveObject } from "./context.ts";
+
+const AWS_SES_S3_SETUP_TAG = "AMAZON_SES_SETUP_NOTIFICATION";
+
+export class AwsMailRetriever {
+ readonly liveMailPrefix = "mail/live/";
+ readonly archiveMailPrefix = "mail/archive/";
+ readonly mailBucket = config.get("awsMailBucket");
+
+ readonly #s3;
+
+ constructor(
+ aws: AwsContext,
+ public readonly inboundDeliverer: MailDeliverer,
+ ) {
+ this.#s3 = new S3Client(aws);
+ }
+
+ async listLiveMails(): Promise<string[]> {
+ log.info("Begin to retrieve live mails.");
+
+ const listCommand = new ListObjectsV2Command({
+ Bucket: this.mailBucket,
+ Prefix: this.liveMailPrefix,
+ });
+ const res = await this.#s3.send(listCommand);
+
+ if (res.Contents == null) {
+ log.warn("Listing live mails in S3 returns null Content.");
+ return [];
+ }
+
+ const result: string[] = [];
+ for (const object of res.Contents) {
+ if (object.Key == null) {
+ log.warn("Listing live mails in S3 returns an object with no Key.");
+ continue;
+ }
+
+ if (object.Key.endsWith(AWS_SES_S3_SETUP_TAG)) continue;
+
+ result.push(object.Key.slice(this.liveMailPrefix.length));
+ }
+ return result;
+ }
+
+ async deliverS3Mail(s3Key: string, recipients: string[] = []) {
+ log.info(`Begin to deliver s3 mail ${s3Key} to ${recipients.join(" ")}...`);
+
+ log.info(`Fetching s3 mail ${s3Key}...`);
+ const mailPath = `${this.liveMailPrefix}${s3Key}`;
+ const command = new GetObjectCommand({
+ Bucket: this.mailBucket,
+ Key: mailPath,
+ });
+ const res = await this.#s3.send(command);
+
+ if (res.Body == null) {
+ throw new Error("S3 mail returns a null body.");
+ }
+
+ const rawMail = await res.Body.transformToString();
+ log.info(`Done fetching s3 mail ${s3Key}.`);
+
+ log.info(`Delivering s3 mail ${s3Key}...`);
+ const mail = new Mail(rawMail);
+ await this.inboundDeliverer.deliver({ mail, recipients: recipients });
+ log.info(`Done delivering s3 mail ${s3Key}.`);
+
+ const date = mail.startSimpleParse().sections().headers().date();
+ const dateString = date?.toFileNameString(true) ?? "invalid-date";
+ const newPath = `${this.archiveMailPrefix}${dateString}/${s3Key}`;
+
+ log.info(`Archiving s3 mail ${s3Key} to ${newPath}...`);
+ await s3MoveObject(this.#s3, this.mailBucket, mailPath, newPath);
+ log.info(`Done delivering s3 mail ${s3Key}...`);
+ }
+
+ async recycleLiveMails() {
+ log.info("Begin to recycle live mails...");
+ const mails = await this.listLiveMails();
+ log.info(`Found ${mails.length} live mails`);
+ for (const s3Key of mails) {
+ await this.deliverS3Mail(s3Key);
+ }
+ }
+}
diff --git a/deno/mail-relay/better-js.ts b/deno/mail-relay/better-js.ts
new file mode 100644
index 0000000..c424a6e
--- /dev/null
+++ b/deno/mail-relay/better-js.ts
@@ -0,0 +1,14 @@
+declare global {
+ interface Date {
+ toFileNameString(dateOnly?: boolean): string;
+ }
+}
+
+Object.defineProperty(Date.prototype, "toFileNameString", {
+ value: function (this: Date, dateOnly?: boolean) {
+ const str = this.toISOString();
+ return dateOnly === true
+ ? str.slice(0, str.indexOf("T"))
+ : str.replaceAll(/:|\./g, "-");
+ },
+});
diff --git a/deno/mail-relay/config.ts b/deno/mail-relay/config.ts
new file mode 100644
index 0000000..d58b163
--- /dev/null
+++ b/deno/mail-relay/config.ts
@@ -0,0 +1,103 @@
+export const APP_PREFIX = "crupest";
+export const APP_NAME = "mail-server";
+
+export interface ConfigItemDefinition {
+ description: string;
+ default?: string;
+ secret?: boolean;
+}
+
+export const CONFIG_DEFINITIONS = {
+ mailDomain: {
+ description: "the part after `@` of an address",
+ },
+ dataPath: {
+ description: "path to save app persistent data",
+ },
+ ldaPath: {
+ description: "full path of lda executable",
+ "default": "/dovecot/libexec/dovecot/dovecot-lda",
+ },
+ inboundFallback: {
+ description: "comma separated addresses used as fallback recipients",
+ "default": "",
+ },
+ awsInboundPath: {
+ description: "(random set) path for aws sns",
+ },
+ awsInboundKey: {
+ description: "(random set) http header Authorization for aws sns",
+ },
+ awsRegion: {
+ description: "aws region",
+ },
+ awsUser: {
+ description: "aws access key id",
+ },
+ awsPassword: {
+ description: "aws secret access key",
+ secret: true,
+ },
+ awsMailBucket: {
+ description: "aws s3 bucket saving raw mails",
+ secret: true,
+ },
+} as const satisfies Record<string, ConfigItemDefinition>;
+
+type ConfigDefinitions = typeof CONFIG_DEFINITIONS;
+type ConfigNames = keyof ConfigDefinitions;
+type ConfigMap = {
+ [K in ConfigNames]: ConfigDefinitions[K] & {
+ readonly env: string;
+ readonly value: string;
+ };
+};
+
+function resolveConfig(): ConfigMap {
+ const result: Record<string, ConfigMap[ConfigNames]> = {};
+ for (const [name, def] of Object.entries(CONFIG_DEFINITIONS)) {
+ const env = `${APP_PREFIX}-${APP_NAME}-${
+ name.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())
+ }`.replaceAll("-", "_").toUpperCase();
+ const value = Deno.env.get(env) ?? (def as ConfigItemDefinition).default;
+ if (value == null) {
+ throw new Error(`Required env ${env} (${def.description}) is not set.`);
+ }
+ result[name] = { ...def, env, value };
+ }
+ return result as ConfigMap;
+}
+
+export class Config {
+ #config = resolveConfig();
+
+ readonly HTTP_HOST = "0.0.0.0";
+ readonly HTTP_PORT = 2345;
+ readonly SMTP_HOST = "127.0.0.1";
+ readonly SMTP_PORT = 2346;
+
+ getAllConfig<K extends ConfigNames>(key: K): ConfigMap[K] {
+ return this.#config[key];
+ }
+
+ get(key: ConfigNames): string {
+ return this.getAllConfig(key).value;
+ }
+
+ getList(key: ConfigNames, separator: string = ","): string[] {
+ const value = this.get(key);
+ if (value.length === 0) return [];
+ return value.split(separator);
+ }
+
+ [Symbol.for("Deno.customInspect")]() {
+ return Object.entries(this.#config).map(([key, item]) =>
+ `${key} [env: ${item.env}]: ${
+ (item as ConfigItemDefinition).secret === true ? "***" : item.value
+ }`
+ ).join("\n");
+ }
+}
+
+const config = new Config();
+export default config;
diff --git a/deno/mail-relay/cron.ts b/deno/mail-relay/cron.ts
new file mode 100644
index 0000000..bf0a0be
--- /dev/null
+++ b/deno/mail-relay/cron.ts
@@ -0,0 +1,43 @@
+export type CronCallback = (task: CronTask) => Promise<void>;
+
+export interface CronTaskConfig {
+ readonly name: string;
+ readonly interval: number;
+ readonly callback: CronCallback;
+ readonly startNow?: boolean;
+}
+
+export class CronTask {
+ #timerTag: number | null = null;
+
+ constructor(public readonly config: CronTaskConfig) {
+ if (config.interval <= 0) {
+ 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,
+ this,
+ );
+ }
+ }
+
+ stop() {
+ if (this.#timerTag != null) {
+ clearInterval(this.#timerTag);
+ this.#timerTag = null;
+ }
+ }
+}
diff --git a/deno/mail-relay/db.test.ts b/deno/mail-relay/db.test.ts
new file mode 100644
index 0000000..60035c4
--- /dev/null
+++ b/deno/mail-relay/db.test.ts
@@ -0,0 +1,23 @@
+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",
+ };
+
+ it("works", async () => {
+ const db = new DbService(":memory:");
+ await db.migrate();
+ await db.addMessageIdMap(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/deno/mail-relay/db.ts b/deno/mail-relay/db.ts
new file mode 100644
index 0000000..9b05e32
--- /dev/null
+++ b/deno/mail-relay/db.ts
@@ -0,0 +1,145 @@
+// 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 {
+}
+
+interface AwsMessageIdMapTable {
+ id: Generated<number>;
+ message_id: string;
+ aws_message_id: string;
+}
+
+interface Database {
+ aws_message_id_map: AwsMessageIdMapTable;
+}
+
+const migrations: Record<string, Migration> = {
+ "0001-init": {
+ // deno-lint-ignore no-explicit-any
+ async up(db: Kysely<any>): Promise<void> {
+ await db.schema
+ .createTable("aws_message_id_map")
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
+ .addColumn("message_id", "text", (col) => col.notNull().unique())
+ .addColumn("aws_message_id", "text", (col) => col.notNull().unique())
+ .execute();
+
+ for (const column of ["message_id", "aws_message_id"]) {
+ await db.schema
+ .createIndex(`aws_message_id_map_${column}`)
+ .on("aws_message_id_map")
+ .column(column)
+ .execute();
+ }
+ },
+
+ // deno-lint-ignore no-explicit-any
+ async down(db: Kysely<any>): Promise<void> {
+ await db.schema.dropTable("aws_message_id_map").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 addMessageIdMap(
+ mail: Insertable<AwsMessageIdMapTable>,
+ ): Promise<number> {
+ const inserted = await this.#kysely.insertInto("aws_message_id_map").values(
+ mail,
+ ).executeTakeFirstOrThrow();
+ return Number(inserted.insertId!);
+ }
+
+ async messageIdToAws(messageId: string): Promise<string | null> {
+ const row = await this.#kysely.selectFrom("aws_message_id_map").where(
+ "message_id",
+ "=",
+ messageId,
+ ).select("aws_message_id").executeTakeFirst();
+ return row?.aws_message_id ?? null;
+ }
+
+ async messageIdFromAws(awsMessageId: string): Promise<string | null> {
+ const row = await this.#kysely.selectFrom("aws_message_id_map")
+ .where("aws_message_id", "=", awsMessageId).select("message_id")
+ .executeTakeFirst();
+ return row?.message_id ?? null;
+ }
+}
diff --git a/deno/mail-relay/deno.json b/deno/mail-relay/deno.json
new file mode 100644
index 0000000..9066b33
--- /dev/null
+++ b/deno/mail-relay/deno.json
@@ -0,0 +1,25 @@
+{
+ "tasks": {
+ "run": "deno run -A aws/app.ts",
+ "test": "deno test -A",
+ "compile": "deno compile -o out/crupest-relay -A aws/app.ts"
+ },
+ "imports": {
+ "@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.821.0",
+ "@aws-sdk/client-sesv2": "npm:@aws-sdk/client-sesv2@^3.821.0",
+ "@db/sqlite": "jsr:@db/sqlite@^0.12.0",
+ "@hono/zod-validator": "npm:@hono/zod-validator@^0.7.0",
+ "@smithy/fetch-http-handler": "npm:@smithy/fetch-http-handler@^5.0.4",
+ "@std/cli": "jsr:@std/cli@^1.0.19",
+ "@std/csv": "jsr:@std/csv@^1.0.6",
+ "@std/encoding": "jsr:@std/encoding@^1.0.10",
+ "@std/expect": "jsr:@std/expect@^1.0.16",
+ "@std/io": "jsr:@std/io@^0.225.2",
+ "@std/path": "jsr:@std/path@^1.1.0",
+ "@std/testing": "jsr:@std/testing@^1.0.13",
+ "email-addresses": "npm:email-addresses@^5.0.0",
+ "hono": "npm:hono@^4.7.11",
+ "kysely": "npm:kysely@^0.28.2",
+ "zod": "npm:zod@^3.25.48"
+ }
+}
diff --git a/deno/mail-relay/dovecot/deliver.ts b/deno/mail-relay/dovecot/deliver.ts
new file mode 100644
index 0000000..92bdc58
--- /dev/null
+++ b/deno/mail-relay/dovecot/deliver.ts
@@ -0,0 +1,102 @@
+import { basename } from "@std/path";
+
+import config from "../config.ts";
+import log from "../log.ts";
+import {
+ Mail,
+ MailDeliverContext,
+ MailDeliverer,
+ RecipientFromHeadersHook,
+} from "../mail.ts";
+
+export class DovecotMailDeliverer extends MailDeliverer {
+ readonly name = "dovecot";
+
+ constructor() {
+ super();
+ this.preHooks.push(
+ new RecipientFromHeadersHook(),
+ );
+ }
+
+ protected override async doDeliver(
+ mail: Mail,
+ context: MailDeliverContext,
+ ): Promise<void> {
+ const ldaPath = config.get("ldaPath");
+ const ldaBinName = basename(ldaPath);
+ const utf8Stream = mail.toUtf8Bytes();
+
+ const recipients = [...context.recipients];
+
+ if (recipients.length === 0) {
+ context.result.message =
+ "Failed to deliver to dovecot, no recipients are specified.";
+ return;
+ }
+
+ log.info(`Deliver to dovecot users: ${recipients.join(", ")}.`);
+
+ for (const recipient of recipients) {
+ try {
+ const commandArgs = ["-d", recipient];
+ log.info(
+ `Run ${ldaBinName} ${commandArgs.join(" ")}...`,
+ );
+
+ const ldaCommand = new Deno.Command(ldaPath, {
+ args: commandArgs,
+ stdin: "piped",
+ stdout: "piped",
+ stderr: "piped",
+ });
+
+ const ldaProcess = ldaCommand.spawn();
+ using logFiles = await log.openLogForProgram(ldaBinName);
+ ldaProcess.stdout.pipeTo(logFiles.stdout.writable);
+ ldaProcess.stderr.pipeTo(logFiles.stderr.writable);
+
+ const stdinWriter = ldaProcess.stdin.getWriter();
+ await stdinWriter.write(utf8Stream);
+ await stdinWriter.close();
+
+ const status = await ldaProcess.status;
+
+ if (status.success) {
+ context.result.recipients.set(recipient, {
+ kind: "done",
+ message: `${ldaBinName} exited with success.`,
+ });
+ } else {
+ let message = `${ldaBinName} exited with error code ${status.code}`;
+
+ if (status.signal != null) {
+ message += ` (signal ${status.signal})`;
+ }
+
+ // https://doc.dovecot.org/main/core/man/dovecot-lda.1.html
+ switch (status.code) {
+ case 67:
+ message += ", recipient user not known";
+ break;
+ case 75:
+ message += ", temporary error";
+ break;
+ }
+
+ message += ".";
+
+ context.result.recipients.set(recipient, { kind: "fail", message });
+ }
+ } catch (cause) {
+ context.result.recipients.set(recipient, {
+ kind: "fail",
+ message: "An error is thrown when running lda: " + cause,
+ cause,
+ });
+ }
+ }
+
+ log.info("Done handling all recipients.");
+ }
+}
diff --git a/deno/mail-relay/dumb-smtp-server.ts b/deno/mail-relay/dumb-smtp-server.ts
new file mode 100644
index 0000000..6c63f5c
--- /dev/null
+++ b/deno/mail-relay/dumb-smtp-server.ts
@@ -0,0 +1,119 @@
+import config from "./config.ts";
+import log from "./log.ts";
+import { MailDeliverer } from "./mail.ts";
+
+const CRLF = "\r\n";
+
+const SERVER_NAME = `[${config.SMTP_HOST}]:${config.SMTP_PORT}`;
+
+const RESPONSES = {
+ "READY": `220 ${SERVER_NAME} SMTP Ready`,
+ "EHLO": `250 ${SERVER_NAME}`,
+ "MAIL": "250 2.1.0 Sender OK",
+ "RCPT": "250 2.1.5 Recipient OK",
+ "DATA": "354 Start mail input; end with <CRLF>.<CRLF>",
+ "QUIT": `211 2.0.0 ${SERVER_NAME} closing connection`,
+ "INVALID": "500 5.5.1 Error: command not recognized",
+} as const;
+
+export class DumbSmtpServer {
+ #deliverer: MailDeliverer;
+
+ constructor(deliverer: MailDeliverer) {
+ this.#deliverer = deliverer;
+ }
+
+ async #handleConnection(conn: Deno.Conn) {
+ using disposeStack = new DisposableStack();
+ disposeStack.defer(() => {
+ log.info("Close smtp session tcp connection.");
+ conn.close();
+ });
+ const writer = conn.writable.getWriter();
+ disposeStack.defer(() => writer.releaseLock());
+ const reader = conn.readable.getReader();
+ disposeStack.defer(() => reader.releaseLock());
+
+ const [decoder, encoder] = [new TextDecoder(), new TextEncoder()];
+ const decode = (data: Uint8Array) => decoder.decode(data);
+ const send = async (s: string) =>
+ await writer.write(encoder.encode(s + CRLF));
+
+ let buffer: string = "";
+ let rawMail: string | null = null;
+
+ await send(RESPONSES["READY"]);
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+
+ buffer += decode(value);
+
+ while (true) {
+ const eolPos = buffer.indexOf(CRLF);
+ if (eolPos === -1) break;
+
+ const line = buffer.slice(0, eolPos);
+ buffer = buffer.slice(eolPos + CRLF.length);
+
+ if (rawMail == null) {
+ log.info("Smtp server received line:", line);
+ const upperLine = line.toUpperCase();
+ if (upperLine.startsWith("EHLO") || upperLine.startsWith("HELO")) {
+ await send(RESPONSES["EHLO"]);
+ } else if (upperLine.startsWith("MAIL FROM:")) {
+ await send(RESPONSES["MAIL"]);
+ } else if (upperLine.startsWith("RCPT TO:")) {
+ await send(RESPONSES["RCPT"]);
+ } else if (upperLine === "DATA") {
+ await send(RESPONSES["DATA"]);
+ log.info("Begin to receive mail data...");
+ rawMail = "";
+ } else if (upperLine === "QUIT") {
+ await send(RESPONSES["QUIT"]);
+ return;
+ } else {
+ log.warn("Smtp server command unrecognized:", line);
+ await send(RESPONSES["INVALID"]);
+ return;
+ }
+ } else {
+ if (line === ".") {
+ try {
+ log.info("Done receiving mail data, begin to relay...");
+ const { message } = await this.#deliverer.deliverRaw(rawMail);
+ await send(`250 2.6.0 ${message}`);
+ rawMail = null;
+ log.info("Done SMTP mail session.");
+ } catch (err) {
+ log.info(err);
+ await send("554 5.3.0 Error: check server log");
+ return;
+ }
+ } else {
+ const dataLine = line.startsWith("..") ? line.slice(1) : line;
+ rawMail += dataLine + CRLF;
+ }
+ }
+ }
+ }
+ }
+
+ async serve() {
+ const listener = Deno.listen({
+ hostname: config.SMTP_HOST,
+ port: config.SMTP_PORT,
+ });
+ listener.unref();
+ log.info(`Dumb SMTP server starts running on port ${config.SMTP_PORT}.`);
+
+ for await (const conn of listener) {
+ try {
+ await this.#handleConnection(conn);
+ } catch (cause) {
+ log.error("One smtp connection session throws an error " + cause);
+ }
+ }
+ }
+}
diff --git a/deno/mail-relay/log.ts b/deno/mail-relay/log.ts
new file mode 100644
index 0000000..ce27eca
--- /dev/null
+++ b/deno/mail-relay/log.ts
@@ -0,0 +1,116 @@
+import { join } from "@std/path";
+import { toWritableStream, Writer } from "@std/io";
+
+import "./better-js.ts";
+
+export interface LogOptions {
+ time?: Date;
+ error?: boolean;
+}
+
+export type LogFile = Pick<Deno.FsFile, "writable"> & Disposable;
+
+export class Log {
+ #path: string | null = null;
+
+ #wrapWriter(writer: Writer): LogFile {
+ return {
+ writable: toWritableStream(writer, { autoClose: false }),
+ [Symbol.dispose]() {},
+ };
+ }
+
+ #stdoutWrapper: LogFile = this.#wrapWriter(Deno.stdout);
+ #stderrWrapper: LogFile = this.#wrapWriter(Deno.stderr);
+
+ constructor() {
+ }
+
+ get path() {
+ return this.#path;
+ }
+
+ set path(path) {
+ this.#path = path;
+ if (path != null) {
+ Deno.mkdirSync(path, { recursive: true });
+ }
+ }
+
+ infoOrError(isError: boolean, ...args: unknown[]) {
+ this[isError ? "error" : "info"].call(this, ...args);
+ }
+
+ info(...args: unknown[]) {
+ console.log(...args);
+ }
+
+ warn(...args: unknown[]) {
+ console.warn(...args);
+ }
+
+ error(...args: unknown[]) {
+ console.error(...args);
+ }
+
+ #extractOptions(options?: LogOptions): Required<LogOptions> {
+ return {
+ time: options?.time ?? new Date(),
+ error: options?.error ?? false,
+ };
+ }
+
+ async openLog(
+ prefix: string,
+ suffix: string,
+ options?: LogOptions,
+ ): Promise<LogFile> {
+ if (prefix.includes("/")) {
+ throw new Error(`Log file prefix ${prefix} contains '/'.`);
+ }
+ if (suffix.includes("/")) {
+ throw new Error(`Log file suffix ${suffix} contains '/'.`);
+ }
+
+ const { time, error } = this.#extractOptions(options);
+ if (this.#path == null) {
+ return error ? this.#stderrWrapper : this.#stdoutWrapper;
+ }
+
+ const logPath = join(
+ this.#path,
+ `${prefix}-${time.toFileNameString()}-${suffix}`,
+ );
+ return await Deno.open(logPath, {
+ read: false,
+ write: true,
+ append: true,
+ create: true,
+ });
+ }
+
+ async openLogForProgram(
+ program: string,
+ options?: Omit<LogOptions, "error">,
+ ): Promise<{ stdout: LogFile; stderr: LogFile } & Disposable> {
+ const stdout = await this.openLog(program, "stdout", {
+ ...options,
+ error: false,
+ });
+ const stderr = await this.openLog(program, "stderr", {
+ ...options,
+ error: true,
+ });
+ return {
+ stdout,
+ stderr,
+ [Symbol.dispose]: () => {
+ stdout[Symbol.dispose]();
+ stderr[Symbol.dispose]();
+ },
+ };
+ }
+}
+
+const log = new Log();
+export default log;
diff --git a/deno/mail-relay/mail.test.ts b/deno/mail-relay/mail.test.ts
new file mode 100644
index 0000000..ee275af
--- /dev/null
+++ b/deno/mail-relay/mail.test.ts
@@ -0,0 +1,143 @@
+import { describe, it } from "@std/testing/bdd";
+import { expect, fn } from "@std/expect";
+
+import { Mail, MailDeliverContext, MailDeliverer } from "./mail.ts";
+
+const mockDate = "Fri, 02 May 2025 08:33:02 +0000";
+const mockMessageId = "mock-message-id@from.mock";
+const mockMessageId2 = "mock-message-id-2@from.mock";
+const mockFromAddress = "mock@from.mock";
+const mockCcAddress = "mock@cc.mock";
+const mockBodyStr = `This is body content.
+Line 2 ${mockMessageId2}
+
+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" <${mockFromAddress}>`],
+ [
+ "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 <${mockCcAddress}>`],
+ ["Subject", "A very long mock\n subject"],
+ ["Message-ID", `<${mockMessageId}>`],
+ ["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]",
+];
+
+describe("Mail", () => {
+ it("simple parse", () => {
+ const parsed = new Mail(mockMailStr).startSimpleParse().sections();
+ expect(parsed.header).toEqual(mockHeaderStr);
+ expect(parsed.body).toEqual(mockBodyStr);
+ expect(parsed.sep).toBe("\n");
+ expect(parsed.eol).toBe("\n");
+ });
+
+ it("simple parse crlf", () => {
+ const parsed = new Mail(mockCrlfMailStr).startSimpleParse().sections();
+ expect(parsed.sep).toBe("\r\n");
+ expect(parsed.eol).toBe("\r\n");
+ });
+
+ it("simple parse date", () => {
+ expect(new Mail(mockMailStr).startSimpleParse().sections().headers().date())
+ .toEqual(new Date(mockDate));
+ });
+
+ it("simple parse headers", () => {
+ expect(
+ new Mail(mockMailStr).startSimpleParse().sections().headers(),
+ ).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 recipients", () => {
+ const mail = new Mail(mockMailStr);
+ expect([...mail.startSimpleParse().sections().headers().recipients()])
+ .toEqual([
+ ...mockToAddresses,
+ mockCcAddress,
+ ]);
+ expect([
+ ...mail.startSimpleParse().sections().headers().recipients({
+ domain: "example.com",
+ }),
+ ]).toEqual([
+ ...mockToAddresses,
+ mockCcAddress,
+ ].filter((a) => a.endsWith("example.com")));
+ });
+
+ it("find all addresses", () => {
+ const mail = new Mail(mockMailStr);
+ expect(mail.simpleFindAllAddresses()).toEqual([
+ "mock@from.mock",
+ "john@example.com",
+ "alice+work@example.com",
+ "team@company.com",
+ "escape@test.com",
+ "just@email.com",
+ "comment@domain.net",
+ "mock@cc.mock",
+ "mock-message-id@from.mock",
+ "mock-message-id-2@from.mock",
+ ]);
+ });
+});
+
+describe("MailDeliverer", () => {
+ class MockMailDeliverer extends MailDeliverer {
+ name = "mock";
+ override doDeliver = fn((_: Mail, ctx: MailDeliverContext) => {
+ ctx.result.recipients.set("*", { kind: "done", message: "success" });
+ return Promise.resolve();
+ }) as MailDeliverer["doDeliver"];
+ }
+ const mockDeliverer = new MockMailDeliverer();
+
+ it("deliver success", async () => {
+ await mockDeliverer.deliverRaw(mockMailStr);
+ expect(mockDeliverer.doDeliver).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/deno/mail-relay/mail.ts b/deno/mail-relay/mail.ts
new file mode 100644
index 0000000..af0df40
--- /dev/null
+++ b/deno/mail-relay/mail.ts
@@ -0,0 +1,340 @@
+import { encodeBase64 } from "@std/encoding/base64";
+import { parse } from "@std/csv/parse";
+import emailAddresses from "email-addresses";
+
+import log from "./log.ts";
+import config from "./config.ts";
+
+class MailSimpleParseError extends Error {
+ constructor(
+ message: string,
+ public readonly text: string,
+ public readonly lineNumber?: number,
+ options?: ErrorOptions,
+ ) {
+ if (lineNumber != null) message += `(at line ${lineNumber})`;
+ super(message, options);
+ }
+}
+
+class MailSimpleParsedHeaders extends Array<[key: string, value: string]> {
+ getFirst(fieldKey: string): string | undefined {
+ for (const [key, value] of this) {
+ if (key.toLowerCase() === fieldKey.toLowerCase()) return value;
+ }
+ return undefined;
+ }
+
+ messageId(): string | undefined {
+ const messageIdField = this.getFirst("message-id");
+ if (messageIdField == null) return undefined;
+
+ const match = messageIdField.match(/\<(.*?)\>/);
+ if (match != null) {
+ return match[1];
+ } else {
+ console.warn("Invalid message-id header of mail: ", messageIdField);
+ return undefined;
+ }
+ }
+
+ date(invalidToUndefined: boolean = true): Date | undefined {
+ const dateField = this.getFirst("date");
+ if (dateField == null) return undefined;
+
+ const date = new Date(dateField);
+ if (invalidToUndefined && isNaN(date.getTime())) {
+ log.warn(`Invalid date string (${dateField}) found in header.`);
+ return undefined;
+ }
+ return date;
+ }
+
+ recipients(options?: { domain?: string; headers?: string[] }): Set<string> {
+ const domain = options?.domain;
+ const headers = options?.headers ?? ["to", "cc", "bcc", "x-original-to"];
+ const recipients = new Set<string>();
+ for (const [key, value] of this) {
+ if (headers.includes(key.toLowerCase())) {
+ emailAddresses.parseAddressList(value)?.flatMap((a) =>
+ a.type === "mailbox" ? a : a.addresses
+ )?.forEach(({ address }) => {
+ if (domain == null || address.endsWith(domain)) {
+ recipients.add(address);
+ }
+ });
+ }
+ }
+ return recipients;
+ }
+}
+
+class MailSimpleParsedSections {
+ header: string;
+ body: string;
+ eol: string;
+ sep: string;
+
+ constructor(raw: string) {
+ const twoEolMatch = raw.match(/(\r?\n)(\r?\n)/);
+ if (twoEolMatch == null) {
+ throw new MailSimpleParseError(
+ "No header/body section separator (2 successive EOLs) found.",
+ raw,
+ );
+ }
+
+ const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]];
+
+ if (eol !== sep) {
+ log.warn("Different EOLs (\\r\\n, \\n) found.");
+ }
+
+ this.header = raw.slice(0, twoEolMatch.index!);
+ this.body = raw.slice(twoEolMatch.index! + eol.length + sep.length);
+ this.eol = eol;
+ this.sep = sep;
+ }
+
+ headers(): MailSimpleParsedHeaders {
+ const headers = new MailSimpleParsedHeaders();
+
+ let field: string | null = null;
+ let lineNumber = 1;
+
+ const handleField = () => {
+ if (field == null) return;
+ const sepPos = field.indexOf(":");
+ if (sepPos === -1) {
+ throw new MailSimpleParseError(
+ "No ':' in the header field.",
+ this.header,
+ lineNumber,
+ );
+ }
+ headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]);
+ field = null;
+ };
+
+ for (const line of this.header.trimEnd().split(/\r?\n|\r/)) {
+ if (line.match(/^\s/)) {
+ if (field == null) {
+ throw new MailSimpleParseError(
+ "Header field starts with a space.",
+ this.header,
+ lineNumber,
+ );
+ }
+ field += line;
+ } else {
+ handleField();
+ field = line;
+ }
+ lineNumber += 1;
+ }
+
+ handleField();
+
+ return headers;
+ }
+}
+
+export class Mail {
+ constructor(public raw: string) {}
+
+ toUtf8Bytes(): Uint8Array {
+ const utf8Encoder = new TextEncoder();
+ return utf8Encoder.encode(this.raw);
+ }
+
+ toBase64(): string {
+ return encodeBase64(this.raw);
+ }
+
+ startSimpleParse() {
+ return { sections: () => new MailSimpleParsedSections(this.raw) };
+ }
+
+ simpleFindAllAddresses(): string[] {
+ const re = /,?\<?([a-z0-9_'+\-\.]+\@[a-z0-9_'+\-\.]+)\>?,?/ig
+ return [...this.raw.matchAll(re)].map(m => m[1])
+ }
+
+ // TODO: Add folding.
+ appendHeaders(headers: [key: string, value: string][]) {
+ const { header, body, sep, eol } = this.startSimpleParse().sections();
+
+ this.raw = header + eol +
+ headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep +
+ body;
+ }
+}
+
+export type MailDeliverResultKind = "done" | "fail";
+
+export interface MailDeliverRecipientResult {
+ kind: MailDeliverResultKind;
+ message: string;
+ cause?: unknown;
+}
+
+export class MailDeliverResult {
+ message: string = "";
+ recipients: Map<string, MailDeliverRecipientResult> = new Map();
+
+ constructor(public mail: Mail) {}
+
+ hasError(): boolean {
+ return this.recipients.size === 0 ||
+ this.recipients.values().some((r) => r.kind !== "done");
+ }
+
+ [Symbol.for("Deno.customInspect")]() {
+ return [
+ `message: ${this.message}`,
+ ...this.recipients.entries().map(([recipient, result]) =>
+ `${recipient} [${result.kind}]: ${result.message}`
+ ),
+ ].join("\n");
+ }
+}
+
+export class MailDeliverContext {
+ readonly recipients: Set<string> = new Set();
+ readonly result;
+
+ constructor(public mail: Mail) {
+ this.result = new MailDeliverResult(this.mail);
+ }
+}
+
+export interface MailDeliverHook {
+ callback(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) {
+ return await this.deliver({ mail: new Mail(rawMail) });
+ }
+
+ async deliver(
+ options: { mail: Mail; recipients?: string[] },
+ ): Promise<MailDeliverResult> {
+ log.info(`Begin to deliver mail via ${this.name}...`);
+
+ const context = new MailDeliverContext(options.mail);
+ options.recipients?.forEach((r) => context.recipients.add(r));
+
+ for (const hook of this.preHooks) {
+ await hook.callback(context);
+ }
+
+ await this.doDeliver(context.mail, context);
+
+ for (const hook of this.postHooks) {
+ await hook.callback(context);
+ }
+
+ log.info("Deliver result:");
+ log.info(context.result);
+
+ if (context.result.hasError()) {
+ throw new Error("Mail failed to deliver.");
+ }
+
+ return context.result;
+ }
+}
+
+export abstract class SyncMailDeliverer extends MailDeliverer {
+ #last: Promise<void> = Promise.resolve();
+
+ override async deliver(
+ options: { mail: Mail; recipients?: string[] },
+ ): Promise<MailDeliverResult> {
+ log.info("The mail deliverer is sync. Wait for last delivering done...");
+ await this.#last;
+ const result = super.deliver(options);
+ this.#last = result.then(() => {}, () => {});
+ return result;
+ }
+}
+
+export class RecipientFromHeadersHook implements MailDeliverHook {
+ callback(context: MailDeliverContext) {
+ if (context.recipients.size !== 0) {
+ log.warn(
+ "Recipients are already filled. Won't set them with ones in headers.",
+ );
+ } else {
+ context.mail.startSimpleParse().sections().headers().recipients({
+ domain: config.get("mailDomain"),
+ }).forEach((r) => context.recipients.add(r));
+
+ log.info(
+ "Recipients found from mail headers: ",
+ [...context.recipients].join(" "),
+ );
+ }
+ return Promise.resolve();
+ }
+}
+
+export class FallbackRecipientHook implements MailDeliverHook {
+ constructor(public fallback: Set<string> = new Set()) {}
+
+ callback(context: MailDeliverContext) {
+ if (context.recipients.size === 0) {
+ log.info(
+ "No recipients, fill with fallback: ",
+ [...this.fallback].join(" "),
+ );
+ this.fallback.forEach((a) => context.recipients.add(a));
+ }
+ return Promise.resolve();
+ }
+}
+
+export class AliasRecipientMailHook implements MailDeliverHook {
+ #aliasFile;
+
+ constructor(aliasFile: string) {
+ this.#aliasFile = aliasFile;
+ }
+
+ async #parseAliasFile(): Promise<Map<string, string>> {
+ const result = new Map();
+ if ((await Deno.stat(this.#aliasFile)).isFile) {
+ log.info(`Found recipients alias file: ${this.#aliasFile}.`);
+ const text = await Deno.readTextFile(this.#aliasFile);
+ const csv = parse(text);
+ for (const [real, ...aliases] of csv) {
+ aliases.forEach((a) => result.set(a, real));
+ }
+ }
+ return result;
+ }
+
+ async callback(context: MailDeliverContext) {
+ const aliases = await this.#parseAliasFile();
+ for (const recipient of [...context.recipients]) {
+ const realRecipients = aliases.get(recipient);
+ if (realRecipients != null) {
+ log.info(
+ `Recipient alias resolved: ${recipient} => ${realRecipients}.`,
+ );
+ context.recipients.delete(recipient);
+ context.recipients.add(realRecipients);
+ }
+ }
+ }
+}