diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ba819f6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,182 @@ +# AGENTS.md — 开发规范 / Development Guidelines + +> 本文件为所有 AI 编码助手(Claude Code、pi、Cursor 等)提供统一的开发指导。 +> This file provides unified development guidelines for all AI coding assistants. + +**最后更新 / Last Updated**: 2026-06-11 + +--- + +## 1. 语言 / Language + +**Always respond in Chinese (中文).** Use the user's language for all conversations and explanations. Code, commands, and technical terms can remain in English. + +始终使用中文回复。代码、命令和技术术语可以保留英文。 + +--- + +## 2. 代码风格 / Code Style + +### 2.1 基本原则 / Basic Principles + +| 规则 / Rule | 说明 / Description | +|-----------|-------------------| +| 遵循现有风格 | Follow existing project conventions | +| 有意义命名 | Use meaningful variable names | +| 函数长度 | Keep functions under **50 lines** | +| 嵌套深度 | Maximum nesting depth: **3 levels** | +| 注释 | Add comments for complex logic only | + +### 2.2 Rust 最佳实践 / Rust Best Practices + +| 规则 / Rule | 说明 / Description | +|-----------|-------------------| +| 错误传播 | Use `?` operator; never use `unwrap()` in non-test code | +| `unsafe` | Avoid; if necessary, add `// SAFETY:` comment | +| `clone()` | Minimize usage; prefer references | +| 魔法数字 | No magic numbers; use named constants | +| 硬编码字符串 | No hardcoded strings; use enums or constants | + +### 2.3 导入规范 / Import Guidelines + +```rust +// 标准库 → 第三方 crate → 本地模块 +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +``` + +--- + +## 3. 禁止模式 / Forbidden Patterns + +以下代码模式在项目中严格禁止: + +| 禁止项 / Forbidden | 说明 / Reason | +|-------------------|--------------| +| `// ── xxxx ──────────` | 禁止使用此类分隔线注释 | +| `unwrap()` / `expect()` (非测试) | 使用 `?` 或安全替代 | +| `panic!()` / `unreachable!()` | 使用错误类型替代 | +| 未处理的 `todo!()` | 必须有对应的 issue 追踪 | +| 注释掉的代码 | 使用 Git 历史追溯 | +| 过深嵌套 (≥4层) | 使用 early return 扁平化逻辑 | +| 过长函数 (>50行) | 拆分为更小的函数 | +| 魔法数字 | 使用 `const` 定义命名常量 | +| 硬编码字符串 | 使用枚举或常量 | + +--- + +## 4. 错误处理 / Error Handling + +### 4.1 错误处理原则 + +| 原则 / Principle | 说明 / Description | +|----------------|-------------------| +| 显式处理 | Handle all errors explicitly; no silent failures | +| 用户友好 | Internal errors are logged; user-facing messages should be helpful | +| 错误上下文 | Use `.context()` or `.map_err()` to add context | + +### 4.2 错误日志格式 + +```rust +tracing::error!( + error = %err, + operation = "operation_name", + "Failed to perform operation" +); +``` + +--- + +## 5. 安全规范 / Security + +| 规则 / Rule | 说明 / Description | +|-----------|-------------------| +| 密钥管理 | Never hardcode secrets or API keys | +| 输入验证 | Always validate and sanitize user input | +| SMTP 安全 | Use TLS for SMTP connections | +| 密码安全 | Use proper hashing (Argon2, bcrypt) | + +--- + +## 6. 工作流程 / Workflow + +### 6.1 开发流程 + +1. **理解先于编写** — Read before write; understand context first +2. **最小变更** — Minimal changes; don't refactor unrelated code +3. **验证变更** — Verify after changes; run tests or check output + +### 6.2 AI 助手工作规范 + +| 规则 / Rule | 说明 / Description | +|-----------|-------------------| +| 先读后写 | Always read existing code before making changes | +| 最小侵入 | Make minimal changes | +| 验证结果 | Run `cargo check` or `cargo test` after changes | +| 解释变更 | Explain what you changed and why | + +### 6.3 常用命令 / Common Commands + +```bash +cargo build # 构建 / Build +cargo check # 快速检查 / Quick check +cargo test # 运行测试 / Run tests +cargo clippy # Lint 检查 / Lint checks +cargo fmt # 格式化 / Format code +``` + +--- + +## 7. Git 规范 / Git Workflow + +### 7.1 提交信息格式 + +``` +(): + +[optional body] + +[optional footer] +``` + +| Type | 说明 / Description | +|------|-------------------| +| `feat` | 新功能 / New feature | +| `fix` | Bug 修复 / Bug fix | +| `refactor` | 重构 / Code refactoring | +| `docs` | 文档 / Documentation | +| `test` | 测试 / Tests | +| `chore` | 构建/工具 / Build/tooling | + +### 7.2 提交原则 + +| 原则 / Principle | 说明 / Description | +|----------------|-------------------| +| 原子提交 | Each commit should address one concern | +| 完整性 | Each commit should leave the codebase in a working state | +| 禁止强制推送 | Never force push to main branch | + +--- + +## 附录 / Appendix + +### 项目架构速查 / Quick Architecture Reference + +``` +emailks — 邮件发送服务 / Email Sending Service + +email.rs → 邮件发送核心 / Email sending core +server.rs → gRPC 服务 / gRPC server +queue.rs → 邮件队化 / Email queue +status.rs → 状态管理 / Status management +error.rs → 错误类型 / Error types +config.rs → 配置管理 / Configuration +proto/ → Protobuf 定义 / Protobuf definitions +``` + +--- + +*This document is maintained by the development team. For questions or suggestions, please open an issue.* diff --git a/Cargo.lock b/Cargo.lock index 2a128ed..048127a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" @@ -171,9 +177,12 @@ name = "emailks" version = "0.1.0" dependencies = [ "dotenvy", + "etcd-client", "lettre", "prost", "prost-types", + "serde", + "serde_json", "tokio", "tokio-stream", "tonic", @@ -182,6 +191,7 @@ dependencies = [ "tonic-prost-build", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -197,7 +207,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcd-client" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed900ba953ca6bf1fadb75e0c6b73d8463b9e2bb6bdb7b4573e8e7295852fbe" +dependencies = [ + "http", + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", + "tower", + "tower-service", ] [[package]] @@ -301,6 +329,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -601,6 +640,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -699,7 +749,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -740,7 +790,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -992,6 +1042,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1002,16 +1066,57 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "schannel" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1050,6 +1155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1129,7 +1235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1138,6 +1244,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1173,10 +1285,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1211,7 +1323,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1235,6 +1347,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1282,6 +1404,7 @@ dependencies = [ "socket2", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-stream", "tower", "tower-layer", @@ -1457,6 +1580,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1475,6 +1604,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1520,6 +1660,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1560,6 +1745,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1569,6 +1763,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1713,6 +1971,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 174e419..803e0b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,12 @@ name = "emailks" path = "main.rs" [dependencies] dotenvy = "0.15" +etcd-client = { version = "0.18", features = ["tls"] } lettre = { version = "0.11", features = ["tokio1-native-tls"] } prost = "0.14" prost-types = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] } tokio-stream = { version = "0.1", features = ["sync"] } tonic = "0.14" @@ -22,6 +25,7 @@ tonic-health = "0.14" tonic-prost = "0.14" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v7"] } [build-dependencies] tonic-prost-build = "0.14" diff --git a/Dockerfile b/Dockerfile index 57f7a66..241c97f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,33 @@ -# ---- builder ---- -FROM rust:1.96-slim-bookworm AS builder - -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config \ - libssl-dev \ - protobuf-compiler \ - && rm -rf /var/lib/apt/lists/* - +FROM rust:1.96-bookworm AS chef +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + protobuf-compiler libprotobuf-dev \ + pkg-config libssl-dev \ + mold clang && \ + rm -rf /var/lib/apt/lists/* +RUN cargo install cargo-chef WORKDIR /app -# Cache dependencies -COPY Cargo.toml Cargo.lock ./ -COPY build.rs ./ -COPY proto/ proto/ -RUN echo '' >lib.rs && \ - echo 'fn main() {}' >main.rs && \ - cargo build --release --bin emailks; \ - rm -f lib.rs main.rs +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json -# Build real binary +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --bin emailks && \ - cp target/release/emailks /app/emailks + strip target/release/emailks -# ---- runtime ---- -FROM debian:bookworm-slim +FROM ubuntu:26.04 +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates libssl3 && \ + rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/emailks /usr/local/bin/emailks -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - libssl3 \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /app/emailks /usr/local/bin/emailks +ENV EMAILKS_HOST=0.0.0.0 +ENV EMAILKS_PORT=50051 EXPOSE 50051 - ENTRYPOINT ["emailks"] diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..661fb84 --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#/bin/bash + +podman build --network=host --build-arg http_proxy= --build-arg https_proxy= -t emailks . \ No newline at end of file diff --git a/config.rs b/config.rs index 3f80e37..8ba5658 100644 --- a/config.rs +++ b/config.rs @@ -54,6 +54,43 @@ pub enum SmtpTls { } impl AppConfig { + pub fn from_etcd( + host: String, port: u16, username: String, password: String, + from_email: String, from_name: String, reply_to: String, + tls: String, timeout_secs: u64, helo_name: String, allow_request_from: bool, + queue_capacity: Option, + listen_addr_str: &str, + ) -> Result { + let tls = match tls.trim().to_ascii_lowercase().as_str() { + "none" | "false" | "0" => SmtpTls::None, + "starttls" | "start_tls" | "start-tls" => SmtpTls::StartTls, + "tls" | "ssl" | "smtps" => SmtpTls::Tls, + _ => SmtpTls::StartTls, + }; + + validate_port("PORT", port)?; + + Ok(Self { + smtp: SmtpConfig { + host, + port, + username: if username.is_empty() { None } else { Some(username) }, + password: if password.is_empty() { None } else { Some(password) }, + from_email: if from_email.is_empty() { None } else { Some(from_email) }, + from_name: if from_name.is_empty() { None } else { Some(from_name) }, + reply_to: if reply_to.is_empty() { None } else { Some(reply_to) }, + tls, + timeout: std::time::Duration::from_secs(timeout_secs), + helo_name: if helo_name.is_empty() { None } else { Some(helo_name) }, + allow_request_from, + }, + queue_capacity, + listen_addr: listen_addr_str + .parse::() + .map_err(|e: std::net::AddrParseError| ConfigError::InvalidEnv { name: "LISTEN_ADDR", reason: e.to_string() })?, + }) + } + pub fn from_env() -> Result { let _ = dotenvy::dotenv(); diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f34b41e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +x-emailks: &emailks + image: emailks + restart: unless-stopped + env_file: .env + environment: + RUST_LOG: info + APP_SMTP_LISTEN_ADDR: 0.0.0.0:50050 + APP_SMTP_QUEUE_CAPACITY: "1000" + +services: + emailks: + <<: *emailks + ports: + - "50050:50050" diff --git a/etcd.rs b/etcd.rs new file mode 100644 index 0000000..519a331 --- /dev/null +++ b/etcd.rs @@ -0,0 +1,163 @@ +use etcd_client::{Client, PutOptions}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// etcd-backed config reader. Priority: etcd > env var > default. +pub struct EtcdConfig { + client: Arc>, + prefix: String, +} + +impl EtcdConfig { + pub async fn connect(endpoints: Vec, prefix: &str) -> Result { + let client = Client::connect(endpoints, None) + .await + .map_err(|e| format!("etcd connect: {e}"))?; + Ok(Self { client: Arc::new(Mutex::new(client)), prefix: prefix.to_string() }) + } + + /// Get config value. Checks etcd first, then env var, then default. + pub async fn get(&self, key: &str, default: &str) -> String { + tracing::info!(key, "etcd get config"); + // 1. Try etcd + let etcd_key = format!("{}config/{}", self.prefix, key); + if let Ok(mut client) = self.client.try_lock() { + if let Ok(resp) = client.get(etcd_key.as_str(), None).await { + if let Some(kv) = resp.kvs().first() { + if let Ok(v) = kv.value_str() { + if !v.is_empty() { + tracing::info!(key, value = v, "config from etcd"); + return v.to_string(); + } + } + } + } + } + // 2. Try env var + if let Ok(v) = std::env::var(key) { + if !v.is_empty() { + tracing::info!(key, value = %v, "config from env"); + return v; + } + } + // 3. Default + tracing::info!(key, value = %default, "config default"); + default.to_string() + } + + /// Get and parse config value. + pub async fn get_parsed(&self, key: &str, default: T) -> T + where + T::Err: std::fmt::Display, + T: std::fmt::Display, + { + let default_str = default.to_string(); + let s = self.get(key, &default_str).await; + s.parse().unwrap_or(default) + } + + /// Set config value in etcd for other services to read. + pub async fn set(&self, key: &str, value: &str) -> Result<(), String> { + let etcd_key = format!("{}config/{}", self.prefix, key); + let mut client = self.client.lock().await; + client + .put(etcd_key, value, None) + .await + .map_err(|e| format!("etcd put: {e}"))?; + Ok(()) + } + + /// Get the underlying etcd client for use by ServiceRegistry. + pub fn client(&self) -> Arc> { + self.client.clone() + } + + /// Get the etcd key prefix. + pub fn prefix(&self) -> &str { + &self.prefix + } +} + +/// Register this service instance in etcd with a lease. +pub struct ServiceRegistry { + client: Arc>, + prefix: String, +} + +impl ServiceRegistry { + pub fn new(client: Arc>, prefix: &str) -> Self { + Self { client, prefix: prefix.to_string() } + } + + /// Register this service under /{prefix}/services/{service_name}/{instance_id} + pub async fn register(&self, service_name: &str, addr: &str) -> Result<(), String> { + let instance_id = uuid::Uuid::now_v7().to_string(); + let addr = addr.to_string(); + let key = format!("{}services/{}/{}", self.prefix, service_name, instance_id); + + let instance = serde_json::json!({ + "addr": &addr, + "port": 0, + "version": env!("CARGO_PKG_VERSION"), + }); + let value = serde_json::to_string(&instance).map_err(|e| format!("json: {e}"))?; + + let lease = { + let mut client = self.client.lock().await; + client + .lease_grant(15, None) + .await + .map_err(|e| format!("lease: {e}"))? + }; + + { + let mut client = self.client.lock().await; + let opts = PutOptions::new().with_lease(lease.id()); + client + .put(key.clone(), value, Some(opts)) + .await + .map_err(|e| format!("put: {e}"))?; + } + + tracing::info!(service = service_name, instance = %instance_id, addr, "registered in etcd"); + + // Spawn keep-alive + let c = self.client.clone(); + tokio::spawn(async move { + loop { + let result = { + let mut client = c.lock().await; + client.lease_keep_alive(lease.id()).await + }; + match result { + Ok((_keeper, mut stream)) => { + use tokio_stream::StreamExt; + while stream.next().await.is_some() {} + } + Err(e) => tracing::warn!(lease_id = lease.id(), error = %e, "keepalive failed"), + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + // Re-grant and re-register + let new_lease = { + let mut client = c.lock().await; + client.lease_grant(15, None).await + }; + if let Ok(lease_resp) = new_lease { + let new_id = lease_resp.id(); + let instance = serde_json::json!({ + "addr": addr, + "port": 0, + "version": env!("CARGO_PKG_VERSION"), + }); + if let Ok(v) = serde_json::to_string(&instance) { + let mut client = c.lock().await; + let opts = PutOptions::new().with_lease(new_id); + let _ = client.put(key.clone(), v, Some(opts)).await; + } + } + } + }); + + Ok(()) + } +} diff --git a/lib.rs b/lib.rs index 6a05a14..d33b3fb 100644 --- a/lib.rs +++ b/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod email; pub mod email_build; pub mod error; +pub mod etcd; pub mod queue; pub mod server; pub mod status; diff --git a/main.rs b/main.rs index 77b0315..3ef9bb0 100644 --- a/main.rs +++ b/main.rs @@ -1,6 +1,7 @@ use emailks::{ - config::AppConfig, email::EmailSender, pb::email::v1::email_service_server::EmailServiceServer, - queue::EmailQueue, server::EmailServiceImpl, + config::AppConfig, email::EmailSender, etcd::{EtcdConfig, ServiceRegistry}, + pb::email::v1::email_service_server::EmailServiceServer, queue::EmailQueue, + server::EmailServiceImpl, }; use tonic::transport::Server; use tracing::{error, info}; @@ -11,18 +12,66 @@ const DEFAULT_QUEUE_CAPACITY: usize = 1_000; async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + 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"); + dotenvy::dotenv().ok(); + + // Phase 1: read etcd endpoints from env (required to bootstrap etcd) + let etcd_endpoints: Vec = std::env::var("ETCD_ENDPOINTS") + .unwrap_or_else(|_| "http://localhost:2379".to_string()) + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + let etcd_prefix = std::env::var("ETCD_KEY_PREFIX") + .unwrap_or_else(|_| "/appks/".to_string()); + + // Phase 2: connect etcd, create config overlay (etcd > env > default) + let etcd = EtcdConfig::connect(etcd_endpoints, &etcd_prefix).await?; + let listen_addr_str = etcd.get("EMAILKS_LISTEN_ADDR", "127.0.0.1:50051").await; + + // Phase 3: register this service so other services (appks) can discover us + let registry = ServiceRegistry::new(etcd.client(), &etcd_prefix); + registry.register("emailks", &listen_addr_str).await?; + + // Phase 4: load SMTP config — each key: etcd first, then env, then default + let smtp_host = etcd.get("APP_SMTP_HOST", "").await; + if smtp_host.is_empty() { + return Err("APP_SMTP_HOST is required (set via etcd or env)".into()); + } + let smtp_port: u16 = etcd.get_parsed("APP_SMTP_PORT", 587u16).await; + let smtp_from_email = etcd.get("APP_SMTP_FROM_EMAIL", "").await; + let smtp_from_name = etcd.get("APP_SMTP_FROM_NAME", "EmailKS").await; + let smtp_reply_to = etcd.get("APP_SMTP_REPLY_TO", "").await; + let smtp_tls = etcd.get("APP_SMTP_TLS", "starttls").await; + let smtp_timeout_secs: u64 = etcd.get_parsed("APP_SMTP_TIMEOUT_SECS", 30u64).await; + let smtp_allow_request_from: bool = etcd.get_parsed("APP_SMTP_ALLOW_REQUEST_FROM", false).await; + let smtp_username = etcd.get("APP_SMTP_USERNAME", "").await; + let smtp_password = etcd.get("APP_SMTP_PASSWORD", "").await; + let smtp_helo_name = etcd.get("APP_SMTP_HELO_NAME", "").await; + + let queue_capacity: Option = { + let s = etcd.get("APP_SMTP_QUEUE_CAPACITY", "").await; + if s.is_empty() { None } else { s.parse().ok() } + }; + + let config = AppConfig::from_etcd( + smtp_host, smtp_port, smtp_username, smtp_password, + smtp_from_email, smtp_from_name, smtp_reply_to, + smtp_tls, smtp_timeout_secs, smtp_helo_name, smtp_allow_request_from, + queue_capacity, + &listen_addr_str, + )?; + + info!(host = %config.smtp.host, port = config.smtp.port, "smtp config loaded (etcd priority)"); let sender = EmailSender::new(config.smtp)?; let (queue, worker) = match config.queue_capacity { - // `Some(0)` explicitly opts into an unbounded queue (mainly for testing). Some(0) => { - info!("creating unbounded queue by explicit configuration"); + info!("creating unbounded queue"); EmailQueue::unbounded() } Some(cap) => { @@ -30,10 +79,7 @@ async fn main() -> Result<(), Box> { EmailQueue::bounded(cap) } None => { - info!( - capacity = DEFAULT_QUEUE_CAPACITY, - "creating bounded queue with default capacity" - ); + info!(capacity = DEFAULT_QUEUE_CAPACITY, "creating bounded queue (default)"); EmailQueue::bounded(DEFAULT_QUEUE_CAPACITY) } }; @@ -60,7 +106,6 @@ async fn main() -> Result<(), Box> { .await?; info!("server stopped"); - if let Err(e) = worker_handle.await { tracing::error!(error = %e, "worker task panicked"); } @@ -70,7 +115,7 @@ async fn main() -> Result<(), Box> { 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"), + Ok(()) => info!("shutdown signal received"), + Err(err) => error!(%err, "failed to install CTRL+C handler"), } }