Files
zhenyi 7b8d0714e7 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
2026-06-12 16:21:04 +08:00

251 lines
7.9 KiB
Rust

use std::{env, fmt, net::SocketAddr, time::Duration};
pub use crate::error::ConfigError;
use crate::ENV_PREFIX;
use tracing;
const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:50051";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppConfig {
pub smtp: SmtpConfig,
pub queue_capacity: Option<usize>,
pub listen_addr: SocketAddr,
}
#[derive(Clone, PartialEq, Eq)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub from_email: Option<String>,
pub from_name: Option<String>,
pub reply_to: Option<String>,
pub tls: SmtpTls,
pub timeout: Duration,
pub helo_name: Option<String>,
pub allow_request_from: bool,
}
impl fmt::Debug for SmtpConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SmtpConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("username", &self.username)
.field("password", &self.password.as_ref().map(|_| "***"))
.field("from_email", &self.from_email)
.field("from_name", &self.from_name)
.field("reply_to", &self.reply_to)
.field("tls", &self.tls)
.field("timeout", &self.timeout)
.field("helo_name", &self.helo_name)
.field("allow_request_from", &self.allow_request_from)
.finish()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SmtpTls {
None,
#[default]
StartTls,
Tls,
}
impl AppConfig {
pub fn from_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();
let queue_capacity: Option<usize> = optional("QUEUE_CAPACITY")?
.map(|v| {
v.parse::<usize>()
.map_err(|e| invalid("QUEUE_CAPACITY", e.to_string()))
})
.transpose()?;
let listen_addr = parse_or(
"LISTEN_ADDR",
DEFAULT_LISTEN_ADDR.parse().expect("valid default address"),
)?;
Ok(Self {
smtp: SmtpConfig::from_env()?,
queue_capacity,
listen_addr,
})
}
}
impl SmtpConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let _ = dotenvy::dotenv();
let host = required("HOST")?;
let port = parse_or("PORT", 587)?;
let from_email = optional("FROM_EMAIL")?;
let reply_to = optional("REPLY_TO")?;
let timeout_secs = parse_or("TIMEOUT_SECS", 30)?;
let allow_request_from = parse_bool("ALLOW_REQUEST_FROM", false)?;
validate_port("PORT", port)?;
validate_email_if_present("FROM_EMAIL", from_email.as_deref())?;
validate_email_if_present("REPLY_TO", reply_to.as_deref())?;
let tls = parse_tls("TLS")?;
if matches!(tls, SmtpTls::None) {
tracing::warn!(
"SMTP TLS is disabled — credentials and email content will be sent in plaintext"
);
}
Ok(Self {
host,
port,
username: optional("USERNAME")?,
password: optional("PASSWORD")?,
from_email,
from_name: optional("FROM_NAME")?,
reply_to,
tls,
timeout: Duration::from_secs(timeout_secs),
helo_name: optional("HELO_NAME")?,
allow_request_from,
})
}
pub fn has_credentials(&self) -> bool {
self.username.as_deref().is_some_and(|v| !v.is_empty())
&& self.password.as_deref().is_some_and(|v| !v.is_empty())
}
}
impl fmt::Display for SmtpTls {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::None => f.write_str("none"),
Self::StartTls => f.write_str("starttls"),
Self::Tls => f.write_str("tls"),
}
}
}
fn required(name: &'static str) -> Result<String, ConfigError> {
match optional(name)? {
Some(value) => Ok(value),
None => Err(ConfigError::MissingEnv { name }),
}
}
fn optional(name: &'static str) -> Result<Option<String>, ConfigError> {
match env::var(env_name(name)) {
Ok(value) if value.trim().is_empty() => Ok(None),
Ok(value) => Ok(Some(value)),
Err(env::VarError::NotPresent) => Ok(None),
Err(env::VarError::NotUnicode(_)) => Err(invalid(name, "value is not valid UTF-8")),
}
}
fn parse_or<T>(name: &'static str, default: T) -> Result<T, ConfigError>
where
T: std::str::FromStr,
T::Err: fmt::Display,
{
optional(name)?
.map(|value| {
value
.parse::<T>()
.map_err(|err| invalid(name, err.to_string()))
})
.unwrap_or(Ok(default))
}
fn parse_tls(name: &'static str) -> Result<SmtpTls, ConfigError> {
let Some(value) = optional(name)? else {
return Ok(SmtpTls::default());
};
match value.trim().to_ascii_lowercase().as_str() {
"none" | "false" | "0" => Ok(SmtpTls::None),
"starttls" | "start_tls" | "start-tls" => Ok(SmtpTls::StartTls),
"tls" | "ssl" | "smtps" => Ok(SmtpTls::Tls),
_ => Err(invalid(name, "expected one of: none, starttls, tls")),
}
}
fn parse_bool(name: &'static str, default: bool) -> Result<bool, ConfigError> {
let Some(value) = optional(name)? else {
return Ok(default);
};
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "y" | "on" => Ok(true),
"false" | "0" | "no" | "n" | "off" => Ok(false),
_ => Err(invalid(name, "expected a boolean value")),
}
}
fn validate_port(name: &'static str, port: u16) -> Result<(), ConfigError> {
if port == 0 {
return Err(invalid(name, "must be between 1 and 65535"));
}
Ok(())
}
fn validate_email_if_present(name: &'static str, value: Option<&str>) -> Result<(), ConfigError> {
if let Some(value) = value
&& !(value.contains('@') && value.split('@').all(|part| !part.is_empty()))
{
return Err(invalid(name, "must be a valid email address"));
}
Ok(())
}
fn env_name(name: &str) -> String {
format!("{ENV_PREFIX}{name}")
}
fn invalid(name: &'static str, reason: impl Into<String>) -> ConfigError {
ConfigError::InvalidEnv {
name,
reason: reason.into(),
}
}