feat(config): integrate etcd-based configuration management

- Add etcd-client dependency with TLS support
- Implement EtcdConfig struct for reading config values with priority: etcd > env > default
- Add ServiceRegistry for service discovery registration in etcd
- Create from_etcd method in AppConfig for loading SMTP configuration
- Update main.rs to use etcd-based config loading with fallback mechanism
- Add etcd module with client connection and key-value operations
- Modify Dockerfile to use cargo-chef for faster builds
- Add docker-compose.yaml for emailks service deployment
- Include AGENTS.md with development guidelines and best practices
- Add build.sh script for podman-based container building
- Update dependencies in Cargo.toml and Cargo.lock
This commit is contained in:
zhenyi
2026-06-12 16:21:04 +08:00
parent c4824ef261
commit 7b8d0714e7
10 changed files with 759 additions and 51 deletions
+182
View File
@@ -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 提交信息格式
```
<type>(<scope>): <subject>
[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.*
Generated
+273 -9
View File
@@ -89,6 +89,12 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -171,9 +177,12 @@ name = "emailks"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"etcd-client",
"lettre", "lettre",
"prost", "prost",
"prost-types", "prost-types",
"serde",
"serde_json",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tonic", "tonic",
@@ -182,6 +191,7 @@ dependencies = [
"tonic-prost-build", "tonic-prost-build",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]
@@ -197,7 +207,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "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]] [[package]]
@@ -301,6 +329,17 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.2"
@@ -601,6 +640,17 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -699,7 +749,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -740,7 +790,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -992,6 +1042,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -1002,16 +1066,57 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "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]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1050,6 +1155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]
@@ -1129,7 +1235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1138,6 +1244,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -1173,10 +1285,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1211,7 +1323,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1235,6 +1347,16 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.18" version = "0.1.18"
@@ -1282,6 +1404,7 @@ dependencies = [
"socket2", "socket2",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-stream", "tokio-stream",
"tower", "tower",
"tower-layer", "tower-layer",
@@ -1457,6 +1580,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -1475,6 +1604,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -1520,6 +1660,51 @@ dependencies = [
"wit-bindgen 0.51.0", "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]] [[package]]
name = "wasm-encoder" name = "wasm-encoder"
version = "0.244.0" version = "0.244.0"
@@ -1560,6 +1745,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -1569,6 +1763,70 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@@ -1713,6 +1971,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
+4
View File
@@ -12,9 +12,12 @@ name = "emailks"
path = "main.rs" path = "main.rs"
[dependencies] [dependencies]
dotenvy = "0.15" dotenvy = "0.15"
etcd-client = { version = "0.18", features = ["tls"] }
lettre = { version = "0.11", features = ["tokio1-native-tls"] } lettre = { version = "0.11", features = ["tokio1-native-tls"] }
prost = "0.14" prost = "0.14"
prost-types = "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 = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
tonic = "0.14" tonic = "0.14"
@@ -22,6 +25,7 @@ tonic-health = "0.14"
tonic-prost = "0.14" tonic-prost = "0.14"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v7"] }
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14" tonic-prost-build = "0.14"
+23 -28
View File
@@ -1,38 +1,33 @@
# ---- builder ---- FROM rust:1.96-bookworm AS chef
FROM rust:1.96-slim-bookworm AS builder RUN apt-get update && \
apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get install -y --no-install-recommends \ protobuf-compiler libprotobuf-dev \
pkg-config \ pkg-config libssl-dev \
libssl-dev \ mold clang && \
protobuf-compiler \ rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* RUN cargo install cargo-chef
WORKDIR /app WORKDIR /app
# Cache dependencies FROM chef AS planner
COPY Cargo.toml Cargo.lock ./ COPY . .
COPY build.rs ./ RUN cargo chef prepare --recipe-path recipe.json
COPY proto/ proto/
RUN echo '' >lib.rs && \
echo 'fn main() {}' >main.rs && \
cargo build --release --bin emailks; \
rm -f lib.rs main.rs
# 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 . . COPY . .
RUN cargo build --release --bin emailks && \ RUN cargo build --release --bin emailks && \
cp target/release/emailks /app/emailks strip target/release/emailks
# ---- runtime ---- FROM ubuntu:26.04
FROM debian:bookworm-slim 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 \ ENV EMAILKS_HOST=0.0.0.0
ca-certificates \ ENV EMAILKS_PORT=50051
libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/emailks /usr/local/bin/emailks
EXPOSE 50051 EXPOSE 50051
ENTRYPOINT ["emailks"] ENTRYPOINT ["emailks"]
+3
View File
@@ -0,0 +1,3 @@
#/bin/bash
podman build --network=host --build-arg http_proxy= --build-arg https_proxy= -t emailks .
+37
View File
@@ -54,6 +54,43 @@ pub enum SmtpTls {
} }
impl AppConfig { 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<usize>,
listen_addr_str: &str,
) -> Result<Self, ConfigError> {
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::<std::net::SocketAddr>()
.map_err(|e: std::net::AddrParseError| ConfigError::InvalidEnv { name: "LISTEN_ADDR", reason: e.to_string() })?,
})
}
pub fn from_env() -> Result<Self, ConfigError> { pub fn from_env() -> Result<Self, ConfigError> {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
+14
View File
@@ -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"
+163
View File
@@ -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<Mutex<Client>>,
prefix: String,
}
impl EtcdConfig {
pub async fn connect(endpoints: Vec<String>, prefix: &str) -> Result<Self, String> {
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<T: std::str::FromStr>(&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<Mutex<Client>> {
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<Mutex<Client>>,
prefix: String,
}
impl ServiceRegistry {
pub fn new(client: Arc<Mutex<Client>>, 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(())
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod config;
pub mod email; pub mod email;
pub mod email_build; pub mod email_build;
pub mod error; pub mod error;
pub mod etcd;
pub mod queue; pub mod queue;
pub mod server; pub mod server;
pub mod status; pub mod status;
+59 -14
View File
@@ -1,6 +1,7 @@
use emailks::{ use emailks::{
config::AppConfig, email::EmailSender, pb::email::v1::email_service_server::EmailServiceServer, config::AppConfig, email::EmailSender, etcd::{EtcdConfig, ServiceRegistry},
queue::EmailQueue, server::EmailServiceImpl, pb::email::v1::email_service_server::EmailServiceServer, queue::EmailQueue,
server::EmailServiceImpl,
}; };
use tonic::transport::Server; use tonic::transport::Server;
use tracing::{error, info}; use tracing::{error, info};
@@ -11,18 +12,66 @@ const DEFAULT_QUEUE_CAPACITY: usize = 1_000;
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .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(); .init();
let config = AppConfig::from_env()?; dotenvy::dotenv().ok();
info!(?config.smtp.host, port = config.smtp.port, "smtp config loaded");
// Phase 1: read etcd endpoints from env (required to bootstrap etcd)
let etcd_endpoints: Vec<String> = 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<usize> = {
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 sender = EmailSender::new(config.smtp)?;
let (queue, worker) = match config.queue_capacity { let (queue, worker) = match config.queue_capacity {
// `Some(0)` explicitly opts into an unbounded queue (mainly for testing).
Some(0) => { Some(0) => {
info!("creating unbounded queue by explicit configuration"); info!("creating unbounded queue");
EmailQueue::unbounded() EmailQueue::unbounded()
} }
Some(cap) => { Some(cap) => {
@@ -30,10 +79,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
EmailQueue::bounded(cap) EmailQueue::bounded(cap)
} }
None => { None => {
info!( info!(capacity = DEFAULT_QUEUE_CAPACITY, "creating bounded queue (default)");
capacity = DEFAULT_QUEUE_CAPACITY,
"creating bounded queue with default capacity"
);
EmailQueue::bounded(DEFAULT_QUEUE_CAPACITY) EmailQueue::bounded(DEFAULT_QUEUE_CAPACITY)
} }
}; };
@@ -60,7 +106,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?; .await?;
info!("server stopped"); info!("server stopped");
if let Err(e) = worker_handle.await { if let Err(e) = worker_handle.await {
tracing::error!(error = %e, "worker task panicked"); tracing::error!(error = %e, "worker task panicked");
} }
@@ -70,7 +115,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn shutdown_signal() { async fn shutdown_signal() {
match tokio::signal::ctrl_c().await { match tokio::signal::ctrl_c().await {
Ok(()) => info!("shutdown signal received, draining..."), Ok(()) => info!("shutdown signal received"),
Err(err) => error!(%err, "failed to install CTRL+C handler, shutting down"), Err(err) => error!(%err, "failed to install CTRL+C handler"),
} }
} }