Skip to content

Commit 6b337ca

Browse files
authored
feat(smtp): editable SMTP config from admin UI + explicit env/DB priority (#139)
1 parent 27a0e37 commit 6b337ca

4 files changed

Lines changed: 452 additions & 51 deletions

File tree

src/commands/config.rs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -588,23 +588,26 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: ConfigCommands) -> Resu
588588
);
589589
}
590590
ConfigCommands::Show => {
591-
let smtp: Option<(String, i32, String, String, Option<String>, bool)> = sqlx::query_as(
592-
"SELECT host, port, username, from_email, from_name, enabled FROM smtp_config LIMIT 1",
593-
)
594-
.fetch_optional(pool)
595-
.await?;
596-
597-
match smtp {
598-
Some((host, port, username, from_email, from_name, enabled)) => {
591+
// Go through `load_smtp_status` so the env block (CALRS_SMTP_*) is
592+
// reflected here too — a direct SELECT would be blind to it and
593+
// misleadingly report "No SMTP configured" when env is in use.
594+
match crate::email::load_smtp_status(pool).await? {
595+
Some(status) => {
599596
println!("{}:", "SMTP".bold());
600-
println!(" Host: {}:{}", host, port);
601-
println!(" Username: {}", username);
602-
println!(
603-
" From: {} <{}>",
604-
from_name.as_deref().unwrap_or(""),
605-
from_email
606-
);
607-
println!(" Enabled: {}", if enabled { "✓" } else { "✗" });
597+
print!(" Host: {}:{}", status.host, status.port);
598+
if status.from_env {
599+
println!(" {}", "(via environment)".dimmed());
600+
} else {
601+
println!();
602+
}
603+
println!(" Username: {}", status.username);
604+
if let Some(from_name) = status.from_name.as_deref() {
605+
println!(" From: {} <{}>", from_name, status.from_email);
606+
} else {
607+
println!(" From: {}", status.from_email);
608+
}
609+
println!(" TLS mode: {}", status.tls_mode);
610+
println!(" Enabled: {}", if status.enabled { "✓" } else { "✗" });
608611
}
609612
None => {
610613
println!("No SMTP configured. Run `calrs config smtp` to set it up.");

src/email.rs

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,24 @@ impl SmtpTlsMode {
7979
pub struct SmtpStatus {
8080
pub host: String,
8181
pub port: u16,
82+
pub username: String,
8283
pub from_email: String,
84+
pub from_name: Option<String>,
85+
pub tls_mode: String,
8386
pub enabled: bool,
8487
pub from_env: bool,
8588
}
8689

90+
impl SmtpTlsMode {
91+
/// Canonical lowercase string used in the DB and `<select>` values.
92+
fn as_str(self) -> &'static str {
93+
match self {
94+
Self::StartTls => "starttls",
95+
Self::Tls => "tls",
96+
}
97+
}
98+
}
99+
87100
impl SmtpConfig {
88101
/// Get "from" Mailbox, compliant with RFC 5322
89102
fn mailbox_from(&self) -> Result<Mailbox> {
@@ -1787,17 +1800,22 @@ const SMTP_ENV_VARS: &[&str] = &[
17871800
"CALRS_SMTP_FROM_NAME",
17881801
];
17891802

1790-
fn required_smtp_env(name: &str) -> Result<String> {
1791-
match std::env::var(name) {
1792-
Ok(value) if !value.trim().is_empty() => Ok(value),
1793-
Ok(_) => bail!("{} must not be empty", name),
1794-
Err(_) => bail!(
1795-
"{} is required when SMTP is configured via environment",
1796-
name
1797-
),
1798-
}
1803+
/// Read an SMTP env var, returning `Some(value)` only when set and non-empty.
1804+
fn optional_smtp_env(name: &str) -> Option<String> {
1805+
std::env::var(name)
1806+
.ok()
1807+
.filter(|value| !value.trim().is_empty())
17991808
}
18001809

1810+
/// Load the SMTP config from the `CALRS_SMTP_*` environment block.
1811+
///
1812+
/// Priority is "full block override": when the required block (host, username,
1813+
/// password, from_email) is complete, the env wins over the database entirely.
1814+
/// When no SMTP var is set, or the required block is only partially set, this
1815+
/// returns `Ok(None)` so the caller falls back to the database config — a stray
1816+
/// or incomplete env var no longer breaks SMTP. A required block that *is*
1817+
/// complete but carries an invalid `PORT`/`TLS_MODE` still surfaces an error,
1818+
/// since that is a genuine misconfiguration to fix rather than silently ignore.
18011819
fn load_smtp_config_from_env() -> Result<Option<SmtpConfig>> {
18021820
if !SMTP_ENV_VARS
18031821
.iter()
@@ -1806,10 +1824,22 @@ fn load_smtp_config_from_env() -> Result<Option<SmtpConfig>> {
18061824
return Ok(None);
18071825
}
18081826

1809-
let host = required_smtp_env("CALRS_SMTP_HOST")?;
1810-
let username = required_smtp_env("CALRS_SMTP_USERNAME")?;
1811-
let password = required_smtp_env("CALRS_SMTP_PASSWORD")?;
1812-
let from_email = required_smtp_env("CALRS_SMTP_FROM_EMAIL")?;
1827+
let (host, username, password, from_email) = match (
1828+
optional_smtp_env("CALRS_SMTP_HOST"),
1829+
optional_smtp_env("CALRS_SMTP_USERNAME"),
1830+
optional_smtp_env("CALRS_SMTP_PASSWORD"),
1831+
optional_smtp_env("CALRS_SMTP_FROM_EMAIL"),
1832+
) {
1833+
(Some(host), Some(username), Some(password), Some(from_email)) => {
1834+
(host, username, password, from_email)
1835+
}
1836+
_ => {
1837+
tracing::warn!(
1838+
"partial CALRS_SMTP_* environment block (missing one of HOST/USERNAME/PASSWORD/FROM_EMAIL); falling back to database SMTP config"
1839+
);
1840+
return Ok(None);
1841+
}
1842+
};
18131843
let port = match std::env::var("CALRS_SMTP_PORT") {
18141844
Ok(value) if value.trim().is_empty() => bail!("CALRS_SMTP_PORT must not be empty"),
18151845
Ok(value) => value.trim().parse::<u16>().map_err(|_| {
@@ -1837,6 +1867,17 @@ fn load_smtp_config_from_env() -> Result<Option<SmtpConfig>> {
18371867
}))
18381868
}
18391869

1870+
/// Returns true when the `CALRS_SMTP_*` environment block governs the config,
1871+
/// meaning the database config is shadowed and must not be edited from the UI.
1872+
///
1873+
/// A complete env block (`Ok(Some)`) or a complete-but-invalid one (`Err`, e.g.
1874+
/// a bad port) both govern — the env overrides the database either way, so the
1875+
/// admin form is locked. A partial/absent block (`Ok(None)`) does not govern:
1876+
/// the app falls back to the database, which stays editable.
1877+
pub fn smtp_env_active() -> bool {
1878+
!matches!(load_smtp_config_from_env(), Ok(None))
1879+
}
1880+
18401881
/// Load SMTP config from environment or database.
18411882
pub async fn load_smtp_config(pool: &SqlitePool, key: &[u8; 32]) -> Result<Option<SmtpConfig>> {
18421883
if let Some(config) = load_smtp_config_from_env()? {
@@ -1881,24 +1922,36 @@ pub async fn load_smtp_status(pool: &SqlitePool) -> Result<Option<SmtpStatus>> {
18811922
return Ok(Some(SmtpStatus {
18821923
host: config.host,
18831924
port: config.port,
1925+
username: config.username,
18841926
from_email: config.from_email,
1927+
from_name: config.from_name,
1928+
tls_mode: config.tls_mode.as_str().to_string(),
18851929
enabled: true,
18861930
from_env: true,
18871931
}));
18881932
}
18891933

1890-
let row: Option<(String, i32, String, bool)> =
1891-
sqlx::query_as("SELECT host, port, from_email, enabled FROM smtp_config LIMIT 1")
1892-
.fetch_optional(pool)
1893-
.await?;
1894-
1895-
Ok(row.map(|(host, port, from_email, enabled)| SmtpStatus {
1896-
host,
1897-
port: port as u16,
1898-
from_email,
1899-
enabled,
1900-
from_env: false,
1901-
}))
1934+
// Prefer the enabled row so the status matches the row `load_smtp_config`
1935+
// would actually send from, in case legacy cleanup ever left extra rows.
1936+
let row: Option<(String, i32, String, String, Option<String>, String, bool)> = sqlx::query_as(
1937+
"SELECT host, port, username, from_email, from_name, tls_mode, enabled
1938+
FROM smtp_config ORDER BY enabled DESC LIMIT 1",
1939+
)
1940+
.fetch_optional(pool)
1941+
.await?;
1942+
1943+
Ok(row.map(
1944+
|(host, port, username, from_email, from_name, tls_mode, enabled)| SmtpStatus {
1945+
host,
1946+
port: port as u16,
1947+
username,
1948+
from_email,
1949+
from_name,
1950+
tls_mode,
1951+
enabled,
1952+
from_env: false,
1953+
},
1954+
))
19021955
}
19031956

19041957
/// Send a test email
@@ -2733,13 +2786,14 @@ mod tests {
27332786
}
27342787

27352788
#[test]
2736-
fn smtp_env_partial_config_errors() {
2789+
fn smtp_env_partial_config_falls_back_to_db() {
27372790
let _env = SmtpEnvGuard::new();
2791+
// Only one of the required vars is set: the block is incomplete, so the
2792+
// env is ignored and the caller falls back to the database config
2793+
// (instead of erroring out and breaking SMTP entirely).
27382794
std::env::set_var("CALRS_SMTP_HOST", "smtp.example.com");
27392795

2740-
let err = smtp_env_error();
2741-
2742-
assert!(err.contains("CALRS_SMTP_USERNAME"));
2796+
assert!(load_smtp_config_from_env().unwrap().is_none());
27432797
}
27442798

27452799
#[test]

0 commit comments

Comments
 (0)