7b8d0714e7
- 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
251 lines
7.9 KiB
Rust
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(),
|
|
}
|
|
}
|