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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+23
-28
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#/bin/bash
|
||||
|
||||
podman build --network=host --build-arg http_proxy= --build-arg https_proxy= -t emailks .
|
||||
@@ -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<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> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<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 (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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn std::error::Error>> {
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user