commit 5fa7a825484353687110c2a31abab6c5e5917420 Author: zhenyi <434836402@qq.com> Date: Sun Jun 7 22:46:30 2026 +0800 chore(project): initialize project with core configuration and dependencies - Add .gitignore and .env.example files for project setup - Create build script for proto compilation with tonic-prost - Generate Cargo.lock with all project dependencies - Configure project structure and ignore patterns for development environment diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a6c2d47 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# SMTP server +APP_SMTP_HOST=smtp.example.com +APP_SMTP_PORT=587 +APP_SMTP_USERNAME= +APP_SMTP_PASSWORD= +APP_SMTP_FROM_EMAIL=noreply@example.com +APP_SMTP_FROM_NAME=EmailKS +APP_SMTP_REPLY_TO= +# one of: none | starttls | tls +APP_SMTP_TLS=starttls +APP_SMTP_TIMEOUT_SECS=30 +APP_SMTP_HELO_NAME= +# Allow per-request From override (disabled by default) +APP_SMTP_ALLOW_REQUEST_FROM=false + +# RPC listen address (defaults to loopback) +APP_SMTP_LISTEN_ADDR=127.0.0.1:50051 + +# Optional queue capacity (bounded to 1000 if unset; set 0 for unbounded) +APP_SMTP_QUEUE_CAPACITY= + +# Log level (trace|debug|info|warn|error) +RUST_LOG=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28ddc54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.env +.env.local +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2a128ed --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1753 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "emailks" +version = "0.1.0" +dependencies = [ + "dotenvy", + "lettre", + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-health", + "tonic-prost", + "tonic-prost-build", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "tokio-native-tls", + "url", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-health" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..174e419 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "emailks" +version = "0.1.0" +edition = "2024" + +[lib] +name = "emailks" +path = "lib.rs" + +[[bin]] +name = "emailks" +path = "main.rs" +[dependencies] +dotenvy = "0.15" +lettre = { version = "0.11", features = ["tokio1-native-tls"] } +prost = "0.14" +prost-types = "0.14" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tonic = "0.14" +tonic-health = "0.14" +tonic-prost = "0.14" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +tonic-prost-build = "0.14" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..76d69e1 --- /dev/null +++ b/build.rs @@ -0,0 +1,15 @@ +use std::{env, path::PathBuf}; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=proto/email.proto"); + println!("cargo:rerun-if-changed=proto"); + + let out_dir = PathBuf::from(env::var("OUT_DIR")?).join("pb"); + std::fs::create_dir_all(&out_dir)?; + + tonic_prost_build::configure() + .out_dir(&out_dir) + .compile_protos(&["proto/email.proto"], &["proto"])?; + + Ok(()) +} diff --git a/config.rs b/config.rs new file mode 100644 index 0000000..4a5377f --- /dev/null +++ b/config.rs @@ -0,0 +1,214 @@ +use std::{env, fmt, net::SocketAddr, time::Duration}; + +pub use crate::error::ConfigError; +use tracing; + +const ENV_PREFIX: &str = "APP_SMTP_"; +const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:50051"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppConfig { + pub smtp: SmtpConfig, + pub queue_capacity: Option, + pub listen_addr: SocketAddr, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub from_email: Option, + pub from_name: Option, + pub reply_to: Option, + pub tls: SmtpTls, + pub timeout: Duration, + pub helo_name: Option, + pub allow_request_from: bool, +} + +impl fmt::Debug for SmtpConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SmtpConfig") + .field("host", &self.host) + .field("port", &self.port) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "***")) + .field("from_email", &self.from_email) + .field("from_name", &self.from_name) + .field("reply_to", &self.reply_to) + .field("tls", &self.tls) + .field("timeout", &self.timeout) + .field("helo_name", &self.helo_name) + .field("allow_request_from", &self.allow_request_from) + .finish() + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum SmtpTls { + None, + #[default] + StartTls, + Tls, +} + +impl AppConfig { + pub fn from_env() -> Result { + let _ = dotenvy::dotenv(); + + let queue_capacity: Option = optional("QUEUE_CAPACITY")? + .map(|v| { + v.parse::() + .map_err(|e| invalid("QUEUE_CAPACITY", e.to_string())) + }) + .transpose()?; + + let listen_addr = parse_or( + "LISTEN_ADDR", + DEFAULT_LISTEN_ADDR.parse().expect("valid default address"), + )?; + + Ok(Self { + smtp: SmtpConfig::from_env()?, + queue_capacity, + listen_addr, + }) + } +} + +impl SmtpConfig { + pub fn from_env() -> Result { + let _ = dotenvy::dotenv(); + + let host = required("HOST")?; + let port = parse_or("PORT", 587)?; + let from_email = optional("FROM_EMAIL")?; + let reply_to = optional("REPLY_TO")?; + let timeout_secs = parse_or("TIMEOUT_SECS", 30)?; + let allow_request_from = parse_bool("ALLOW_REQUEST_FROM", false)?; + + validate_port("PORT", port)?; + validate_email_if_present("FROM_EMAIL", from_email.as_deref())?; + validate_email_if_present("REPLY_TO", reply_to.as_deref())?; + + let tls = parse_tls("TLS")?; + if matches!(tls, SmtpTls::None) { + tracing::warn!( + "SMTP TLS is disabled — credentials and email content will be sent in plaintext" + ); + } + + Ok(Self { + host, + port, + username: optional("USERNAME")?, + password: optional("PASSWORD")?, + from_email, + from_name: optional("FROM_NAME")?, + reply_to, + tls, + timeout: Duration::from_secs(timeout_secs), + helo_name: optional("HELO_NAME")?, + allow_request_from, + }) + } + + pub fn has_credentials(&self) -> bool { + self.username.as_deref().is_some_and(|v| !v.is_empty()) + && self.password.as_deref().is_some_and(|v| !v.is_empty()) + } +} + +impl fmt::Display for SmtpTls { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::None => f.write_str("none"), + Self::StartTls => f.write_str("starttls"), + Self::Tls => f.write_str("tls"), + } + } +} + +fn required(name: &'static str) -> Result { + match optional(name)? { + Some(value) => Ok(value), + None => Err(ConfigError::MissingEnv { name }), + } +} + +fn optional(name: &'static str) -> Result, ConfigError> { + match env::var(env_name(name)) { + Ok(value) if value.trim().is_empty() => Ok(None), + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(invalid(name, "value is not valid UTF-8")), + } +} + +fn parse_or(name: &'static str, default: T) -> Result +where + T: std::str::FromStr, + T::Err: fmt::Display, +{ + optional(name)? + .map(|value| { + value + .parse::() + .map_err(|err| invalid(name, err.to_string())) + }) + .unwrap_or(Ok(default)) +} + +fn parse_tls(name: &'static str) -> Result { + let Some(value) = optional(name)? else { + return Ok(SmtpTls::default()); + }; + + match value.trim().to_ascii_lowercase().as_str() { + "none" | "false" | "0" => Ok(SmtpTls::None), + "starttls" | "start_tls" | "start-tls" => Ok(SmtpTls::StartTls), + "tls" | "ssl" | "smtps" => Ok(SmtpTls::Tls), + _ => Err(invalid(name, "expected one of: none, starttls, tls")), + } +} + +fn parse_bool(name: &'static str, default: bool) -> Result { + let Some(value) = optional(name)? else { + return Ok(default); + }; + + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "y" | "on" => Ok(true), + "false" | "0" | "no" | "n" | "off" => Ok(false), + _ => Err(invalid(name, "expected a boolean value")), + } +} + +fn validate_port(name: &'static str, port: u16) -> Result<(), ConfigError> { + if port == 0 { + return Err(invalid(name, "must be between 1 and 65535")); + } + Ok(()) +} + +fn validate_email_if_present(name: &'static str, value: Option<&str>) -> Result<(), ConfigError> { + if let Some(value) = value + && !(value.contains('@') && value.split('@').all(|part| !part.is_empty())) + { + return Err(invalid(name, "must be a valid email address")); + } + Ok(()) +} + +fn env_name(name: &str) -> String { + format!("{ENV_PREFIX}{name}") +} + +fn invalid(name: &'static str, reason: impl Into) -> ConfigError { + ConfigError::InvalidEnv { + name, + reason: reason.into(), + } +} diff --git a/email.rs b/email.rs new file mode 100644 index 0000000..6cd9003 --- /dev/null +++ b/email.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; + +pub use crate::error::EmailError; +use crate::{ + config::SmtpConfig, email_build::build_message_from_parts, pb::email::v1::SendEmailRequest, + queue::EmailJob, +}; + +pub(crate) type Mailer = AsyncSmtpTransport; + +#[derive(Clone)] +pub struct EmailSender { + config: Arc, + mailer: Mailer, +} + +impl EmailSender { + pub fn new(config: SmtpConfig) -> Result { + let mailer = crate::email_build::build_mailer(&config)?; + Ok(Self { + config: Arc::new(config), + mailer, + }) + } + + pub async fn send(&self, request: &SendEmailRequest) -> Result<(), EmailError> { + let message = build_message_from_parts(&self.config, request)?; + self.mailer + .send(message) + .await + .map_err(|e| EmailError::Send(e.to_string()))?; + Ok(()) + } + + pub async fn send_job(&self, job: &EmailJob) -> Result<(), EmailError> { + self.send(&job.request).await + } +} diff --git a/email_build.rs b/email_build.rs new file mode 100644 index 0000000..0bb2b83 --- /dev/null +++ b/email_build.rs @@ -0,0 +1,288 @@ +use lettre::{ + Address, AsyncSmtpTransport, Message, Tokio1Executor, + message::{ + Attachment as LettreAttachment, Body, Mailbox, MultiPart, SinglePart, + header::{ContentType, HeaderName, HeaderValue}, + }, + transport::smtp::{self, authentication::Credentials, extension::ClientId}, +}; + +use crate::{ + config::{SmtpConfig, SmtpTls}, + error::EmailError, + pb::email::v1::{Attachment, EmailAddress, EmailPriority, SendEmailRequest}, +}; + +pub(crate) fn build_mailer( + config: &SmtpConfig, +) -> Result, EmailError> { + let mut builder = match config.tls { + SmtpTls::None => { + smtp::AsyncSmtpTransport::::builder_dangerous(&config.host) + } + SmtpTls::StartTls => { + smtp::AsyncSmtpTransport::::starttls_relay(&config.host) + .map_err(|e| EmailError::BuildTransport(e.to_string()))? + } + SmtpTls::Tls => smtp::AsyncSmtpTransport::::relay(&config.host) + .map_err(|e| EmailError::BuildTransport(e.to_string()))?, + } + .port(config.port) + .timeout(Some(config.timeout)); + + match (&config.username, &config.password) { + (Some(u), Some(p)) => { + builder = builder.credentials(Credentials::new(u.clone(), p.clone())); + } + (None, None) => {} + _ => return Err(EmailError::IncompleteCredentials), + } + + if let Some(name) = config.helo_name.as_deref().and_then(non_empty) { + builder = builder.hello_name(ClientId::Domain(name.to_owned())); + } + Ok(builder.build()) +} + +pub(crate) fn build_body( + builder: lettre::message::MessageBuilder, + request: &SendEmailRequest, +) -> Result { + match select_body_multipart(request) { + BodyKind::Both(mp) => builder + .multipart(mp) + .map_err(|e| EmailError::BuildMessage(e.to_string())), + BodyKind::Html(sp) => builder + .singlepart(sp) + .map_err(|e| EmailError::BuildMessage(e.to_string())), + BodyKind::Plain(sp) => builder + .singlepart(sp) + .map_err(|e| EmailError::BuildMessage(e.to_string())), + } +} + +pub(crate) fn build_mixed_body(request: &SendEmailRequest) -> MultiPart { + let mixed = MultiPart::mixed(); + match select_body_multipart(request) { + BodyKind::Both(mp) => mixed.multipart(mp), + BodyKind::Html(sp) => mixed.singlepart(sp), + BodyKind::Plain(sp) => mixed.singlepart(sp), + } +} + +enum BodyKind { + Both(MultiPart), + Html(SinglePart), + Plain(SinglePart), +} + +fn select_body_multipart(request: &SendEmailRequest) -> BodyKind { + if !request.text_body.is_empty() && !request.html_body.is_empty() { + BodyKind::Both(MultiPart::alternative_plain_html( + request.text_body.clone(), + request.html_body.clone(), + )) + } else if !request.html_body.is_empty() { + BodyKind::Html(SinglePart::html(request.html_body.clone())) + } else { + BodyKind::Plain(SinglePart::plain(request.text_body.clone())) + } +} + +pub(crate) fn build_single_attachment( + index: usize, + att: &Attachment, +) -> Result { + let filename = if att.filename.trim().is_empty() { + format!("attachment-{index}") + } else { + att.filename.clone() + }; + + if att.data.is_empty() && !att.url.trim().is_empty() { + return Err(EmailError::UnsupportedAttachmentUrl { + filename, + url: att.url.clone(), + }); + } + let ct: ContentType = if att.content_type.trim().is_empty() { + "application/octet-stream" + .parse() + .expect("valid static mime") + } else { + att.content_type + .parse() + .map_err(|e: lettre::message::header::ContentTypeErr| { + EmailError::InvalidContentType { + filename: filename.clone(), + value: att.content_type.clone(), + reason: e.to_string(), + } + })? + }; + Ok(LettreAttachment::new(filename).body(Body::new(att.data.clone()), ct)) +} + +pub(crate) fn mailbox_from_email_address( + field: &'static str, + value: &EmailAddress, +) -> Result { + mailbox_from_parts(field, &value.email, non_empty(&value.name)) +} + +pub(crate) fn mailbox_from_parts( + field: &'static str, + email: &str, + name: Option<&str>, +) -> Result { + let address = email + .trim() + .parse::
() + .map_err(|e| EmailError::InvalidAddress { + field, + value: email.to_owned(), + reason: e.to_string(), + })?; + Ok(Mailbox::new(name.map(ToOwned::to_owned), address)) +} + +pub(crate) fn apply_custom_headers( + mut builder: lettre::message::MessageBuilder, + request: &SendEmailRequest, +) -> Result { + for (name, value) in &request.headers { + if non_empty(name).is_none() { + continue; + } + if is_managed_header(name) { + return Err(EmailError::ForbiddenHeader { name: name.clone() }); + } + if value.contains(['\r', '\n']) { + return Err(EmailError::InvalidHeader { + name: name.clone(), + reason: "header value must not contain CR or LF".to_owned(), + }); + } + let hn = + HeaderName::new_from_ascii(name.clone()).map_err(|e| EmailError::InvalidHeader { + name: name.clone(), + reason: e.to_string(), + })?; + builder = builder.raw_header(HeaderValue::new(hn, value.clone())); + } + Ok(builder) +} + +pub(crate) fn apply_priority_headers( + builder: lettre::message::MessageBuilder, + priority: i32, +) -> lettre::message::MessageBuilder { + match EmailPriority::try_from(priority).unwrap_or(EmailPriority::Unspecified) { + EmailPriority::High => builder + .raw_header(hv("X-Priority", "1")) + .raw_header(hv("Importance", "high")), + EmailPriority::Low => builder + .raw_header(hv("X-Priority", "5")) + .raw_header(hv("Importance", "low")), + EmailPriority::Normal | EmailPriority::Unspecified => builder, + } +} + +fn hv(name: &'static str, value: &str) -> HeaderValue { + HeaderValue::new(HeaderName::new_from_ascii_str(name), value.to_owned()) +} + +fn is_managed_header(name: &str) -> bool { + matches!( + name.trim().to_ascii_lowercase().as_str(), + "from" + | "to" + | "cc" + | "bcc" + | "subject" + | "reply-to" + | "date" + | "mime-version" + | "content-type" + | "content-transfer-encoding" + | "content-disposition" + ) +} + +pub(crate) fn non_empty(value: &str) -> Option<&str> { + let v = value.trim(); + (!v.is_empty()).then_some(v) +} + +pub fn build_message_from_parts( + config: &SmtpConfig, + request: &SendEmailRequest, +) -> Result { + if request.to.is_empty() { + return Err(EmailError::MissingRecipients); + } + if request.text_body.is_empty() && request.html_body.is_empty() { + return Err(EmailError::BuildMessage( + "email must have at least a text or HTML body".to_owned(), + )); + } + + let from = resolve_sender(config, request)?; + let mut builder = Message::builder() + .from(from) + .subject(request.subject.clone()); + + for r in &request.to { + builder = builder.to(mailbox_from_email_address("to", r)?); + } + for r in &request.cc { + builder = builder.cc(mailbox_from_email_address("cc", r)?); + } + for r in &request.bcc { + builder = builder.bcc(mailbox_from_email_address("bcc", r)?); + } + if let Some(reply_to) = resolve_reply_to(config, request)? { + builder = builder.reply_to(reply_to); + } + + builder = apply_custom_headers(builder, request)?; + builder = apply_priority_headers(builder, request.priority); + + if request.attachments.is_empty() { + return build_body(builder, request); + } + let mut mixed = build_mixed_body(request); + for (i, att) in request.attachments.iter().enumerate() { + mixed = mixed.singlepart(build_single_attachment(i, att)?); + } + builder + .multipart(mixed) + .map_err(|e| EmailError::BuildMessage(e.to_string())) +} + +fn resolve_sender(config: &SmtpConfig, request: &SendEmailRequest) -> Result { + if let Some(from) = &request.from + && !from.email.trim().is_empty() + { + if !config.allow_request_from { + return Err(EmailError::RequestSenderDisabled); + } + return mailbox_from_email_address("from", from); + } + let email = config + .from_email + .as_deref() + .filter(|v| !v.trim().is_empty()) + .ok_or(EmailError::MissingSender)?; + mailbox_from_parts("from", email, config.from_name.as_deref()) +} + +fn resolve_reply_to( + config: &SmtpConfig, + request: &SendEmailRequest, +) -> Result, EmailError> { + let email = non_empty(&request.reply_to).or(config.reply_to.as_deref()); + email + .map(|v| mailbox_from_parts("reply_to", v, None)) + .transpose() +} diff --git a/error.rs b/error.rs new file mode 100644 index 0000000..49837d9 --- /dev/null +++ b/error.rs @@ -0,0 +1,147 @@ +use std::fmt; + +const ENV_PREFIX: &str = "APP_SMTP_"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + MissingEnv { name: &'static str }, + InvalidEnv { name: &'static str, reason: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueueError { + Closed, + Full, + IdExhausted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmailError { + MissingSender, + MissingRecipients, + RequestSenderDisabled, + IncompleteCredentials, + InvalidAddress { + field: &'static str, + value: String, + reason: String, + }, + InvalidContentType { + filename: String, + value: String, + reason: String, + }, + InvalidHeader { + name: String, + reason: String, + }, + ForbiddenHeader { + name: String, + }, + UnsupportedAttachmentUrl { + filename: String, + url: String, + }, + BuildTransport(String), + BuildMessage(String), + Send(String), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingEnv { name } => { + write!(f, "missing environment variable {ENV_PREFIX}{name}") + } + Self::InvalidEnv { name, reason } => { + write!( + f, + "invalid environment variable {ENV_PREFIX}{name}: {reason}" + ) + } + } + } +} + +impl fmt::Display for QueueError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Closed => f.write_str("email queue is closed"), + Self::Full => f.write_str("email queue is full"), + Self::IdExhausted => f.write_str("email queue id space is exhausted"), + } + } +} + +impl fmt::Display for EmailError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingSender => { + f.write_str("missing sender: set request.from or APP_SMTP_FROM_EMAIL") + } + Self::MissingRecipients => { + f.write_str("missing recipients: request.to must not be empty") + } + Self::RequestSenderDisabled => { + f.write_str("request.from is disabled: use APP_SMTP_FROM_EMAIL or enable APP_SMTP_ALLOW_REQUEST_FROM") + } + Self::IncompleteCredentials => { + f.write_str("APP_SMTP_USERNAME and APP_SMTP_PASSWORD must be set together") + } + Self::InvalidAddress { + field, + value, + reason, + } => write!(f, "invalid email address in {field} ({value}): {reason}"), + Self::InvalidContentType { + filename, + value, + reason, + } => write!( + f, + "invalid content type for attachment {filename} ({value}): {reason}" + ), + Self::InvalidHeader { name, reason } => { + write!(f, "invalid custom header {name}: {reason}") + } + Self::ForbiddenHeader { name } => { + write!(f, "custom header {name} is managed by the mail builder") + } + Self::UnsupportedAttachmentUrl { filename, url } => write!( + f, + "attachment {filename} uses url {url}, but URL attachment fetching is not supported" + ), + Self::BuildTransport(reason) => write!(f, "failed to build SMTP transport: {reason}"), + Self::BuildMessage(reason) => write!(f, "failed to build email message: {reason}"), + Self::Send(reason) => write!(f, "failed to send email: {reason}"), + } + } +} + +impl std::error::Error for ConfigError {} +impl std::error::Error for QueueError {} +impl std::error::Error for EmailError {} + +impl EmailError { + /// 返回 true 表示该错误不可重试,应直接销毁任务 + pub fn is_terminal(&self) -> bool { + matches!( + self, + Self::MissingSender + | Self::MissingRecipients + | Self::RequestSenderDisabled + | Self::IncompleteCredentials + | Self::InvalidAddress { .. } + | Self::InvalidContentType { .. } + | Self::InvalidHeader { .. } + | Self::ForbiddenHeader { .. } + | Self::UnsupportedAttachmentUrl { .. } + | Self::BuildTransport(_) + | Self::BuildMessage(_) + ) + } + + pub fn is_retryable(&self) -> bool { + !self.is_terminal() + } +} diff --git a/lib.rs b/lib.rs new file mode 100644 index 0000000..5acd80e --- /dev/null +++ b/lib.rs @@ -0,0 +1,15 @@ +pub mod config; +pub mod email; +pub mod email_build; +pub mod error; +pub mod queue; +pub mod server; +pub mod status; + +pub mod pb { + pub mod email { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/pb/email.v1.rs")); + } + } +} diff --git a/main.rs b/main.rs new file mode 100644 index 0000000..324fc06 --- /dev/null +++ b/main.rs @@ -0,0 +1,75 @@ +use emailks::{ + config::AppConfig, email::EmailSender, pb::email::v1::email_service_server::EmailServiceServer, + queue::EmailQueue, server::EmailServiceImpl, +}; +use tonic::transport::Server; +use tracing::{error, info}; + +const DEFAULT_QUEUE_CAPACITY: usize = 1_000; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let config = AppConfig::from_env()?; + info!(?config.smtp.host, port = config.smtp.port, "smtp config loaded"); + + let sender = EmailSender::new(config.smtp)?; + let (queue, worker) = match config.queue_capacity { + Some(0) => { + info!("creating unbounded queue by explicit configuration"); + EmailQueue::unbounded() + } + Some(cap) => { + info!(capacity = cap, "creating bounded queue"); + EmailQueue::bounded(cap) + } + None => { + info!( + capacity = DEFAULT_QUEUE_CAPACITY, + "creating bounded queue with default capacity" + ); + EmailQueue::bounded(DEFAULT_QUEUE_CAPACITY) + } + }; + + let store = queue.status_store().clone(); + let worker_handle = worker.spawn(move |job| { + let s = sender.clone(); + async move { s.send_job(&job).await } + }); + + let addr = config.listen_addr; + let svc = EmailServiceImpl::new(queue, store); + + let (health, health_svc) = tonic_health::server::health_reporter(); + health + .set_serving::>() + .await; + + info!(%addr, "gRPC server starting"); + Server::builder() + .add_service(health_svc) + .add_service(EmailServiceServer::new(svc)) + .serve_with_shutdown(addr, shutdown_signal()) + .await?; + + info!("server stopped"); + + if let Err(e) = worker_handle.await { + tracing::error!(error = %e, "worker task panicked"); + } + + Ok(()) +} + +async fn shutdown_signal() { + match tokio::signal::ctrl_c().await { + Ok(()) => info!("shutdown signal received, draining..."), + Err(err) => error!(%err, "failed to install CTRL+C handler, shutting down"), + } +} diff --git a/proto/email.proto b/proto/email.proto new file mode 100644 index 0000000..dfb0953 --- /dev/null +++ b/proto/email.proto @@ -0,0 +1,88 @@ +syntax = "proto3"; +package email.v1; + +import "google/protobuf/timestamp.proto"; + + + +enum EmailPriority { + EMAIL_PRIORITY_UNSPECIFIED = 0; + EMAIL_PRIORITY_LOW = 1; + EMAIL_PRIORITY_NORMAL = 2; + EMAIL_PRIORITY_HIGH = 3; +} + +enum SendStatus { + SEND_STATUS_UNSPECIFIED = 0; + SEND_STATUS_QUEUED = 1; + SEND_STATUS_SENT = 2; + SEND_STATUS_FAILED = 3; + SEND_STATUS_SENDING = 4; +} + + + +message EmailAddress { + string email = 1; + string name = 2; +} + +message Attachment { + string filename = 1; + string content_type = 2; + bytes data = 3; + string url = 4; +} + + +message SendEmailRequest { + EmailAddress from = 1; + repeated EmailAddress to = 2; + repeated EmailAddress cc = 3; + repeated EmailAddress bcc = 4; + string subject = 5; + string text_body = 6; + string html_body = 7; + repeated Attachment attachments = 8; + EmailPriority priority = 9; + map headers = 10; + string reply_to = 11; +} + +message SendEmailResponse { + string message_id = 1; + SendStatus status = 2; + string provider = 3; + google.protobuf.Timestamp sent_at = 4; +} + +message BatchSendEmailRequest { + repeated SendEmailRequest emails = 1; + bool fail_fast = 2; +} + +message BatchSendEmailResponse { + repeated SendEmailResponse results = 1; + int32 success_count = 2; + int32 failure_count = 3; +} + +message GetEmailStatusRequest { + string message_id = 1; +} + +message GetEmailStatusResponse { + string message_id = 1; + SendStatus status = 2; + string error_detail = 3; + google.protobuf.Timestamp updated_at = 4; +} + + +service EmailService { + rpc SendEmail(SendEmailRequest) returns (SendEmailResponse); + rpc BatchSendEmail(BatchSendEmailRequest) returns (BatchSendEmailResponse); + rpc GetEmailStatus(GetEmailStatusRequest) returns (GetEmailStatusResponse); + rpc StreamBatchStatus(BatchSendEmailRequest) + returns (stream SendEmailResponse); +} \ No newline at end of file diff --git a/queue.rs b/queue.rs new file mode 100644 index 0000000..a769f21 --- /dev/null +++ b/queue.rs @@ -0,0 +1,310 @@ +use std::{ + fmt, + future::Future, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use tokio::sync::{mpsc, watch}; +use tracing::{error, info, warn}; + +pub use crate::error::QueueError; +use crate::{error::EmailError, pb::email::v1::SendEmailRequest, status::JobStatusStore}; + +pub const MAX_FAILURES: u8 = 3; +const RETRY_BASE_DELAY: Duration = Duration::from_millis(100); +const RETRY_MAX_DELAY: Duration = Duration::from_secs(5); + +#[derive(Clone)] +pub struct EmailQueue { + sender: QueueSender, + next_id: Arc, + status_store: JobStatusStore, + shutdown: Arc, +} + +pub struct EmailQueueWorker { + receiver: QueueReceiver, + requeue_sender: QueueSender, + status_store: JobStatusStore, + shutdown_rx: watch::Receiver, +} + +#[derive(Clone)] +pub struct EmailJob { + pub id: u64, + pub request: Arc, + pub failed_attempts: u8, +} + +#[derive(Clone)] +enum QueueSender { + Unbounded(mpsc::UnboundedSender), + Bounded(mpsc::Sender), +} + +enum QueueReceiver { + Unbounded(mpsc::UnboundedReceiver), + Bounded(mpsc::Receiver), +} + +struct QueueShutdown { + tx: watch::Sender, +} + +impl Drop for QueueShutdown { + fn drop(&mut self) { + let _ = self.tx.send(true); + } +} + +fn new_shutdown_pair() -> (Arc, watch::Receiver) { + let (tx, rx) = watch::channel(false); + (Arc::new(QueueShutdown { tx }), rx) +} + +fn retry_delay(attempt: u8, job_id: u64) -> Duration { + let multiplier = 1u32 << u32::from(attempt.saturating_sub(1)).min(8); + let base = RETRY_BASE_DELAY.saturating_mul(multiplier); + let jitter = Duration::from_millis(job_id % 100); + base.saturating_add(jitter).min(RETRY_MAX_DELAY) +} + +impl QueueSender { + fn send(&self, job: EmailJob) -> Result<(), QueueError> { + match self { + Self::Unbounded(tx) => tx.send(job).map_err(|_| QueueError::Closed), + Self::Bounded(tx) => tx.try_send(job).map_err(|e| match e { + mpsc::error::TrySendError::Full(_) => QueueError::Full, + mpsc::error::TrySendError::Closed(_) => QueueError::Closed, + }), + } + } +} + +impl QueueReceiver { + async fn recv(&mut self) -> Option { + match self { + Self::Unbounded(rx) => rx.recv().await, + Self::Bounded(rx) => rx.recv().await, + } + } +} + +impl EmailQueue { + pub fn unbounded() -> (Self, EmailQueueWorker) { + let store = JobStatusStore::new(); + Self::build(mpsc::unbounded_channel(), store) + } + + pub fn bounded(capacity: usize) -> (Self, EmailQueueWorker) { + let store = JobStatusStore::new(); + Self::build_bounded(mpsc::channel(capacity), store) + } + + fn build( + (tx, rx): ( + mpsc::UnboundedSender, + mpsc::UnboundedReceiver, + ), + store: JobStatusStore, + ) -> (Self, EmailQueueWorker) { + let (shutdown, shutdown_rx) = new_shutdown_pair(); + let sender = QueueSender::Unbounded(tx); + let queue = Self { + sender: sender.clone(), + next_id: Arc::new(AtomicU64::new(1)), + status_store: store.clone(), + shutdown, + }; + let worker = EmailQueueWorker { + receiver: QueueReceiver::Unbounded(rx), + requeue_sender: sender, + status_store: store, + shutdown_rx, + }; + (queue, worker) + } + + fn build_bounded( + (tx, rx): (mpsc::Sender, mpsc::Receiver), + store: JobStatusStore, + ) -> (Self, EmailQueueWorker) { + let (shutdown, shutdown_rx) = new_shutdown_pair(); + let sender = QueueSender::Bounded(tx.clone()); + let queue = Self { + sender: sender.clone(), + next_id: Arc::new(AtomicU64::new(1)), + status_store: store.clone(), + shutdown, + }; + let worker = EmailQueueWorker { + receiver: QueueReceiver::Bounded(rx), + requeue_sender: sender, + status_store: store, + shutdown_rx, + }; + (queue, worker) + } +} + +impl EmailQueue { + pub fn enqueue(&self, request: SendEmailRequest) -> Result { + if *self.shutdown.tx.borrow() { + return Err(QueueError::Closed); + } + let id = self.next_job_id()?; + self.status_store.set_queued(id); + let job = EmailJob { + id, + request: Arc::new(request), + failed_attempts: 0, + }; + if let Err(err) = self.sender.send(job) { + self.status_store.remove(id); + return Err(err); + } + info!(id, "email job enqueued"); + Ok(id) + } + + /// Enqueues multiple email requests. + /// + /// Returns `Ok(ids)` on full success. On partial failure, returns + /// `Err(QueueError)` — note that some emails may have already been + /// enqueued before the failure. Callers should handle duplicates + /// if retrying the full batch. + pub fn enqueue_batch(&self, requests: I) -> Result, QueueError> + where + I: IntoIterator, + { + requests.into_iter().map(|r| self.enqueue(r)).collect() + } + + pub fn status_store(&self) -> &JobStatusStore { + &self.status_store + } + + fn next_job_id(&self) -> Result { + self.next_id + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)) + .map_err(|_| QueueError::IdExhausted) + } +} + +impl EmailQueueWorker { + pub async fn run(mut self, mut consume: F) + where + F: FnMut(EmailJob) -> Fut, + Fut: Future>, + { + loop { + tokio::select! { + changed = self.shutdown_rx.changed() => { + if changed.is_err() || *self.shutdown_rx.borrow() { + info!("queue worker stopped: shutdown requested"); + break; + } + } + job = self.receiver.recv() => { + let Some(job) = job else { + info!("queue worker stopped: channel closed"); + break; + }; + self.consume_job(job, &mut consume).await; + } + } + } + } + + pub fn spawn(self, consume: F) -> tokio::task::JoinHandle<()> + where + F: FnMut(EmailJob) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + tokio::spawn(async move { self.run(consume).await }) + } + + async fn consume_job(&self, mut job: EmailJob, consume: &mut F) + where + F: FnMut(EmailJob) -> Fut, + Fut: Future>, + { + self.status_store.set_sending(job.id); + + match consume(job.clone()).await { + Ok(()) => { + info!(id = job.id, "email sent"); + self.status_store.set_sent(job.id); + } + Err(err) => { + if err.is_terminal() { + warn!(id = job.id, %err, "terminal error, destroying job"); + self.status_store.set_failed(job.id, err.to_string()); + return; + } + + job.failed_attempts = job.failed_attempts.saturating_add(1); + if job.failed_attempts >= MAX_FAILURES { + error!( + id = job.id, + %err, + attempts = job.failed_attempts, + "max failures reached, destroying job" + ); + self.status_store.set_failed(job.id, err.to_string()); + return; + } + + let delay = retry_delay(job.failed_attempts, job.id); + warn!( + id = job.id, + %err, + attempt = job.failed_attempts, + max = MAX_FAILURES, + delay_ms = delay.as_millis(), + "retryable failure, requeuing after backoff" + ); + + if *self.shutdown_rx.borrow() { + self.status_store + .set_failed(job.id, "shutdown before retry".to_owned()); + return; + } + let mut shutdown_rx = self.shutdown_rx.clone(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + self.status_store.set_failed(job.id, "shutdown before retry".to_owned()); + return; + } + } + } + + let requeue_id = job.id; + if let Err(e) = self.requeue_sender.send(job) { + error!(id = requeue_id, %e, "failed to requeue"); + self.status_store + .set_failed(requeue_id, format!("requeue failed: {e}")); + } + } + } + } + + pub fn status_store(&self) -> &JobStatusStore { + &self.status_store + } +} + +impl fmt::Debug for EmailJob { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EmailJob") + .field("id", &self.id) + .field("failed_attempts", &self.failed_attempts) + .finish_non_exhaustive() + } +} diff --git a/server.rs b/server.rs new file mode 100644 index 0000000..06bd275 --- /dev/null +++ b/server.rs @@ -0,0 +1,226 @@ +use std::pin::Pin; + +use tokio::time::{self, Duration, Instant}; + +use tokio::sync::mpsc; +use tokio_stream::{Stream, wrappers::ReceiverStream}; +use tonic::{Request, Response, Status}; +use tracing::warn; + +const STREAM_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(300); +const STREAM_STATUS_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +use crate::{ + error::QueueError, + pb::email::v1::{ + BatchSendEmailRequest, BatchSendEmailResponse, GetEmailStatusRequest, + GetEmailStatusResponse, SendEmailRequest, SendEmailResponse, SendStatus, + email_service_server::EmailService, + }, + queue::EmailQueue, + status::JobStatusStore, +}; + +#[derive(Clone)] +pub struct EmailServiceImpl { + queue: EmailQueue, + store: JobStatusStore, +} + +impl EmailServiceImpl { + pub fn new(queue: EmailQueue, store: JobStatusStore) -> Self { + Self { queue, store } + } +} + +fn map_queue_err(err: QueueError) -> Status { + match err { + QueueError::Closed => Status::unavailable("queue closed"), + QueueError::Full => Status::resource_exhausted("queue full, try later"), + QueueError::IdExhausted => Status::resource_exhausted("queue id space exhausted"), + } +} + +fn build_response(id: u64, status: SendStatus) -> SendEmailResponse { + SendEmailResponse { + message_id: id.to_string(), + status: status.into(), + provider: String::new(), + sent_at: None, + } +} + +fn build_failed_response(id: Option, detail: String) -> SendEmailResponse { + SendEmailResponse { + message_id: id.map(|v| v.to_string()).unwrap_or_default(), + status: SendStatus::Failed.into(), + provider: detail, + sent_at: None, + } +} + +#[tonic::async_trait] +impl EmailService for EmailServiceImpl { + async fn send_email( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let id = self.queue.enqueue(req).map_err(map_queue_err)?; + Ok(Response::new(build_response(id, SendStatus::Queued))) + } + + async fn batch_send_email( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let total = req.emails.len(); + let mut success = 0i32; + let mut failures = 0i32; + let mut results = Vec::with_capacity(total); + + for email in req.emails { + match self.queue.enqueue(email) { + Ok(id) => { + success += 1; + results.push(build_response(id, SendStatus::Queued)); + } + Err(e) => { + failures += 1; + warn!(%e, "batch enqueue failed for one email"); + if req.fail_fast { + warn!( + successful = success, + failed = failures, + "fail_fast triggered, returning partial results" + ); + break; + } + } + } + } + Ok(Response::new(BatchSendEmailResponse { + results, + success_count: success, + failure_count: failures, + })) + } + + async fn get_email_status( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let id: u64 = req + .message_id + .parse() + .map_err(|_| Status::invalid_argument("message_id must be a valid u64"))?; + + let entry = self + .store + .get(id) + .ok_or_else(|| Status::not_found(format!("message_id {id} not found")))?; + + Ok(Response::new(GetEmailStatusResponse { + message_id: id.to_string(), + status: entry.status.into(), + error_detail: entry.error.unwrap_or_default(), + updated_at: None, + })) + } + + type StreamBatchStatusStream = + Pin> + Send>>; + + async fn stream_batch_status( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let mut ids = Vec::with_capacity(req.emails.len()); + let mut immediate_results = Vec::new(); + + for email in req.emails { + match self.queue.enqueue(email) { + Ok(id) => ids.push(id), + Err(err) => { + immediate_results.push(Ok(build_failed_response(None, err.to_string()))) + } + } + } + + let id_set: std::collections::HashSet = ids.iter().copied().collect(); + let store = self.store.clone(); + let (tx, rx) = mpsc::channel(ids.len().saturating_add(immediate_results.len()).max(1)); + + tokio::spawn(async move { + for result in immediate_results { + if tx.send(result).await.is_err() { + return; + } + } + + let mut interval = time::interval(STREAM_STATUS_POLL_INTERVAL); + let deadline = Instant::now() + STREAM_STATUS_TIMEOUT; + let mut reported = std::collections::HashSet::new(); + + loop { + tokio::select! { + _ = tx.closed() => return, + _ = time::sleep_until(deadline) => { + for id in id_set.difference(&reported) { + let response = build_failed_response( + Some(*id), + "status stream timed out before terminal state".to_owned(), + ); + if tx.send(Ok(response)).await.is_err() { + return; + } + } + break; + } + _ = interval.tick() => { + for id in &id_set { + if reported.contains(id) { + continue; + } + if let Some(entry) = store.get(*id) { + match entry.status { + SendStatus::Sent => { + if tx + .send(Ok(build_response(*id, SendStatus::Sent))) + .await + .is_err() + { + return; + } + reported.insert(*id); + } + SendStatus::Failed => { + let response = build_failed_response( + Some(*id), + entry.error.unwrap_or_else(|| "unknown".into()), + ); + if tx.send(Ok(response)).await.is_err() { + return; + } + reported.insert(*id); + } + _ => {} + } + } + } + + if reported.len() == id_set.len() { + break; + } + } + } + } + }); + + let stream: Self::StreamBatchStatusStream = Box::pin(ReceiverStream::new(rx)); + Ok(Response::new(stream)) + } +} diff --git a/status.rs b/status.rs new file mode 100644 index 0000000..54a46cc --- /dev/null +++ b/status.rs @@ -0,0 +1,118 @@ +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; + +use tracing; + +use crate::pb::email::v1::SendStatus; + +const STATUS_TTL: Duration = Duration::from_secs(24 * 60 * 60); +const MAX_STATUS_ENTRIES: usize = 10_000; + +#[derive(Debug, Clone, Default)] +pub struct JobStatusStore { + inner: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct JobStatusEntry { + pub status: SendStatus, + pub error: Option, + updated_at: Instant, +} + +impl JobStatusStore { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn set_queued(&self, id: u64) { + self.write(id, SendStatus::Queued, None); + } + + pub fn set_sending(&self, id: u64) { + self.write(id, SendStatus::Sending, None); + } + + pub fn set_sent(&self, id: u64) { + self.write(id, SendStatus::Sent, None); + } + + pub fn set_failed(&self, id: u64, error: String) { + self.write(id, SendStatus::Failed, Some(error)); + } + + pub fn get(&self, id: u64) -> Option { + let guard = match self.inner.read() { + Ok(g) => g, + Err(poisoned) => { + tracing::error!("JobStatusStore read lock poisoned, recovering"); + poisoned.into_inner() + } + }; + guard.get(&id).cloned() + } + + pub fn remove(&self, id: u64) { + let mut guard = match self.inner.write() { + Ok(g) => g, + Err(poisoned) => { + tracing::error!("JobStatusStore write lock poisoned, recovering"); + poisoned.into_inner() + } + }; + guard.remove(&id); + } + + pub fn all_done(&self, ids: &[u64]) -> bool { + let guard = match self.inner.read() { + Ok(g) => g, + Err(_) => return false, + }; + ids.iter().all(|id| { + matches!( + guard.get(id).map(|e| e.status), + Some(SendStatus::Sent | SendStatus::Failed) + ) + }) + } + + fn write(&self, id: u64, status: SendStatus, error: Option) { + let mut guard = match self.inner.write() { + Ok(g) => g, + Err(poisoned) => { + tracing::error!("JobStatusStore write lock poisoned, recovering"); + poisoned.into_inner() + } + }; + prune_statuses(&mut guard); + guard.insert( + id, + JobStatusEntry { + status, + error, + updated_at: Instant::now(), + }, + ); + } +} + +fn prune_statuses(entries: &mut HashMap) { + let now = Instant::now(); + entries.retain(|_, entry| now.duration_since(entry.updated_at) <= STATUS_TTL); + + while entries.len() >= MAX_STATUS_ENTRIES { + let Some(oldest_id) = entries + .iter() + .min_by_key(|(_, entry)| entry.updated_at) + .map(|(id, _)| *id) + else { + break; + }; + entries.remove(&oldest_id); + } +} diff --git a/tests/config_tests.rs b/tests/config_tests.rs new file mode 100644 index 0000000..fd682cf --- /dev/null +++ b/tests/config_tests.rs @@ -0,0 +1,135 @@ +use emailks::config::{AppConfig, SmtpTls}; +use emailks::error::ConfigError; +use std::sync::Mutex; +use std::time::Duration; + +/// Serialize tests that mutate process-wide environment variables. +static TEST_MUTEX: Mutex<()> = Mutex::new(()); + +macro_rules! set_env { + ($k:expr, $v:expr) => { + unsafe { std::env::set_var($k, $v) } + }; +} +macro_rules! rm_env { + ($k:expr) => { + unsafe { std::env::remove_var($k) } + }; +} + +fn clear_smtp_env() { + for var in &[ + "APP_SMTP_HOST", + "APP_SMTP_PORT", + "APP_SMTP_USERNAME", + "APP_SMTP_PASSWORD", + "APP_SMTP_FROM_EMAIL", + "APP_SMTP_FROM_NAME", + "APP_SMTP_REPLY_TO", + "APP_SMTP_TLS", + "APP_SMTP_TIMEOUT_SECS", + "APP_SMTP_HELO_NAME", + "APP_SMTP_ALLOW_REQUEST_FROM", + "APP_SMTP_LISTEN_ADDR", + "APP_SMTP_QUEUE_CAPACITY", + ] { + rm_env!(var); + } +} + +#[test] +fn parse_smtp_config_full() { + let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_smtp_env(); + set_env!("APP_SMTP_HOST", "smtp.test.com"); + set_env!("APP_SMTP_PORT", "465"); + set_env!("APP_SMTP_USERNAME", "user"); + set_env!("APP_SMTP_PASSWORD", "pass"); + set_env!("APP_SMTP_FROM_EMAIL", "from@test.com"); + set_env!("APP_SMTP_FROM_NAME", "Test"); + set_env!("APP_SMTP_REPLY_TO", "reply@test.com"); + set_env!("APP_SMTP_TLS", "tls"); + set_env!("APP_SMTP_TIMEOUT_SECS", "60"); + set_env!("APP_SMTP_HELO_NAME", "helo.test.com"); + + let s = &AppConfig::from_env().unwrap().smtp; + assert_eq!(s.host, "smtp.test.com"); + assert_eq!(s.port, 465); + assert_eq!(s.username.as_deref(), Some("user")); + assert_eq!(s.password.as_deref(), Some("pass")); + assert_eq!(s.from_email.as_deref(), Some("from@test.com")); + assert_eq!(s.from_name.as_deref(), Some("Test")); + assert_eq!(s.reply_to.as_deref(), Some("reply@test.com")); + assert_eq!(s.tls, SmtpTls::Tls); + assert_eq!(s.timeout, Duration::from_secs(60)); + assert_eq!(s.helo_name.as_deref(), Some("helo.test.com")); + assert!(!s.allow_request_from); +} + +#[test] +fn parse_smtp_config_minimal() { + let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_smtp_env(); + set_env!("APP_SMTP_HOST", "smtp.test.com"); + + let s = &AppConfig::from_env().unwrap().smtp; + assert_eq!(s.host, "smtp.test.com"); + assert_eq!(s.port, 587); + assert_eq!(s.tls, SmtpTls::StartTls); + assert_eq!(s.timeout, Duration::from_secs(30)); + assert!(s.username.is_none()); + assert_eq!( + AppConfig::from_env().unwrap().listen_addr.to_string(), + "127.0.0.1:50051" + ); +} + +#[test] +fn missing_host_is_error() { + let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_smtp_env(); + let err = AppConfig::from_env().unwrap_err(); + assert!(matches!(err, ConfigError::MissingEnv { name: "HOST" })); +} + +#[test] +fn tls_variants() { + let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_smtp_env(); + set_env!("APP_SMTP_HOST", "h"); + for (value, expected) in [ + ("none", SmtpTls::None), + ("false", SmtpTls::None), + ("starttls", SmtpTls::StartTls), + ("start_tls", SmtpTls::StartTls), + ("tls", SmtpTls::Tls), + ("ssl", SmtpTls::Tls), + ] { + set_env!("APP_SMTP_TLS", value); + assert_eq!(AppConfig::from_env().unwrap().smtp.tls, expected); + } +} + +#[test] +fn invalid_port_is_error() { + let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_smtp_env(); + set_env!("APP_SMTP_HOST", "h"); + set_env!("APP_SMTP_PORT", "0"); + assert!(matches!( + AppConfig::from_env().unwrap_err(), + ConfigError::InvalidEnv { name: "PORT", .. } + )); +} + +#[test] +fn queue_capacity_parsing() { + let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_smtp_env(); + set_env!("APP_SMTP_HOST", "h"); + set_env!("APP_SMTP_QUEUE_CAPACITY", "100"); + assert_eq!(AppConfig::from_env().unwrap().queue_capacity, Some(100)); + + rm_env!("APP_SMTP_QUEUE_CAPACITY"); + assert_eq!(AppConfig::from_env().unwrap().queue_capacity, None); +} diff --git a/tests/email_tests.rs b/tests/email_tests.rs new file mode 100644 index 0000000..ac2090b --- /dev/null +++ b/tests/email_tests.rs @@ -0,0 +1,102 @@ +use emailks::{ + email_build::build_message_from_parts, + error::EmailError, + pb::email::v1::{EmailAddress, SendEmailRequest}, +}; +use std::time::Duration; + +fn test_config() -> emailks::config::SmtpConfig { + emailks::config::SmtpConfig { + host: "localhost".into(), + port: 1025, + username: None, + password: None, + from_email: Some("sender@test.com".into()), + from_name: Some("Sender".into()), + reply_to: None, + tls: emailks::config::SmtpTls::None, + timeout: Duration::from_secs(5), + helo_name: None, + allow_request_from: false, + } +} + +fn req_with_to(to: &str) -> SendEmailRequest { + SendEmailRequest { + from: None, + to: vec![EmailAddress { + email: to.into(), + name: String::new(), + }], + cc: vec![], + bcc: vec![], + subject: "Hello".into(), + text_body: "World".into(), + html_body: String::new(), + attachments: vec![], + priority: 0, + headers: Default::default(), + reply_to: String::new(), + } +} + +#[test] +fn sender_from_request() { + let mut cfg = test_config(); + cfg.allow_request_from = true; + let mut req = req_with_to("to@test.com"); + req.from = Some(EmailAddress { + email: "req@test.com".into(), + name: "Request".into(), + }); + + let msg = build_message_from_parts(&cfg, &req).unwrap(); + let body = String::from_utf8(msg.formatted()).unwrap(); + assert!(body.contains("From: Request ")); + assert!(body.contains("To: to@test.com")); + assert!(body.contains("Subject: Hello")); +} + +#[test] +fn sender_from_config() { + let mut req = req_with_to("to@test.com"); + req.text_body = String::new(); + req.html_body = "

Hi

".into(); + + let msg = build_message_from_parts(&test_config(), &req).unwrap(); + let body = String::from_utf8(msg.formatted()).unwrap(); + assert!(body.contains("From: Sender ")); + assert!(body.contains("Content-Type: text/html")); +} + +#[test] +fn missing_sender_error() { + let mut cfg = test_config(); + cfg.from_email = None; + let req = req_with_to("to@test.com"); + let err = build_message_from_parts(&cfg, &req).unwrap_err(); + assert!(matches!(err, EmailError::MissingSender)); +} + +#[test] +fn missing_recipients_error() { + let req = SendEmailRequest { + from: Some(EmailAddress { + email: "x@x.com".into(), + name: String::new(), + }), + to: vec![], + cc: vec![], + bcc: vec![], + subject: "s".into(), + text_body: "b".into(), + html_body: String::new(), + attachments: vec![], + priority: 0, + headers: Default::default(), + reply_to: String::new(), + }; + + let err = build_message_from_parts(&test_config(), &req).unwrap_err(); + assert!(matches!(err, EmailError::MissingRecipients)); +} diff --git a/tests/queue_tests.rs b/tests/queue_tests.rs new file mode 100644 index 0000000..1d19ac9 --- /dev/null +++ b/tests/queue_tests.rs @@ -0,0 +1,127 @@ +use emailks::{ + error::EmailError, + pb::email::v1::{EmailAddress, SendEmailRequest}, + queue::EmailQueue, +}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +fn dummy_request() -> SendEmailRequest { + SendEmailRequest { + from: None, + to: vec![], + cc: vec![], + bcc: vec![], + subject: "test".into(), + text_body: "body".into(), + html_body: String::new(), + attachments: vec![], + priority: 0, + headers: Default::default(), + reply_to: String::new(), + } +} + +#[tokio::test] +async fn enqueue_and_consume_success() { + let (queue, worker) = EmailQueue::unbounded(); + let counter = Arc::new(AtomicUsize::new(0)); + let c = counter.clone(); + + let mut req = dummy_request(); + req.to.push(EmailAddress { + email: "test@example.com".into(), + name: String::new(), + }); + + let id = queue.enqueue(req).expect("enqueue should work"); + assert!(id > 0); + + worker.spawn(move |_job| { + let c = c.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Ok::<(), EmailError>(()) + } + }); + + // Wait for async consumption + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_eq!(counter.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn retry_then_succeed() { + let (queue, worker) = EmailQueue::unbounded(); + let attempts = Arc::new(AtomicUsize::new(0)); + let a = attempts.clone(); + + let _id = queue.enqueue(dummy_request()).unwrap(); + + worker.spawn(move |_job| { + let a = a.clone(); + async move { + let n = a.fetch_add(1, Ordering::SeqCst); + if n < 2 { + Err(EmailError::Send("temp failure".into())) + } else { + Ok(()) + } + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(700)).await; + assert_eq!(attempts.load(Ordering::SeqCst), 3); // 2 fails + 1 success +} + +#[tokio::test] +async fn terminal_error_destroyed_immediately() { + let (queue, worker) = EmailQueue::unbounded(); + let store = queue.status_store().clone(); + let attempts = Arc::new(AtomicUsize::new(0)); + let a = attempts.clone(); + + let id = queue.enqueue(dummy_request()).unwrap(); + + worker.spawn(move |_job| { + let a = a.clone(); + async move { + a.fetch_add(1, Ordering::SeqCst); + Err(EmailError::MissingRecipients) + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_eq!(attempts.load(Ordering::SeqCst), 1); + + let entry = store.get(id).expect("should have status entry"); + assert_eq!(entry.status, emailks::pb::email::v1::SendStatus::Failed); +} + +#[tokio::test] +async fn bounded_channel_blocks_when_full() { + let (queue, _worker) = EmailQueue::bounded(1); + let _id1 = queue.enqueue(dummy_request()).unwrap(); + // Second enqueue should fail with Full + let err = queue.enqueue(dummy_request()).unwrap_err(); + assert!(matches!(err, emailks::error::QueueError::Full)); +} + +#[tokio::test] +async fn status_store_tracks_lifecycle() { + let (queue, worker) = EmailQueue::unbounded(); + let store = queue.status_store().clone(); + + let id = queue.enqueue(dummy_request()).unwrap(); + // Status should be Queued + let entry = store.get(id).unwrap(); + assert_eq!(entry.status, emailks::pb::email::v1::SendStatus::Queued); + + worker.spawn(move |_job| async move { Ok::<(), EmailError>(()) }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let entry = store.get(id).unwrap(); + assert_eq!(entry.status, emailks::pb::email::v1::SendStatus::Sent); +}