@@ -79,11 +79,24 @@ impl SmtpTlsMode {
7979pub 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+
87100impl 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.
18011819fn 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.
18411882pub 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