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"
|
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"
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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 {
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
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;
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user