Skip to main content

peisear_notify/
config.rs

1//! SMTP configuration, sourced from environment.
2//!
3//! Per Q3 of the 0.16.0 design: SMTP is operator territory,
4//! not user territory. Credentials live in environment
5//! variables read at startup; there is no in-app form for them.
6//! This is the same shape as the existing `JWT_SECRET` /
7//! `DATABASE_URL` / `BIND_ADDR` pattern.
8//!
9//! ## Variables
10//!
11//! | Variable | Required | Notes |
12//! |---|---|---|
13//! | `SMTP_HOST` | for email channel | e.g. `smtp.example.com` |
14//! | `SMTP_PORT` | optional | default 465 (implicit TLS) |
15//! | `SMTP_TLS_MODE` | optional | `implicit` or `starttls`; auto from port if unset |
16//! | `SMTP_USER` | for email channel | SMTP AUTH username |
17//! | `SMTP_PASSWORD` | for email channel | SMTP AUTH password |
18//! | `SMTP_FROM_ADDRESS` | for email channel | `From:` envelope address |
19//! | `SMTP_FROM_NAME` | optional | display name for `From:` header |
20//!
21//! Both implicit TLS (port 465) and STARTTLS (port 587) are
22//! supported by `wasm-smtp` 0.9 and the `wasm-smtp-tokio`
23//! adapter. We pick the mode either by `SMTP_TLS_MODE` (when
24//! set) or, when unset, by port number: 465 → implicit, 587 →
25//! STARTTLS, anything else → implicit (the modern default per
26//! upstream's recommendation).
27//!
28//! ## Behaviour when unconfigured
29//!
30//! Per Q4 of the design: graceful failure at send time, not at
31//! startup. [`SmtpConfig::from_env`] returns `None` if any
32//! required variable is missing; the caller logs a warning at
33//! startup and continues. Subsequent send attempts fail at the
34//! channel layer (logged), the audit row records `dispatched_via`
35//! without `email`, and the in-app channel still works.
36//!
37//! Rationale: peisear should remain useful in deployments that
38//! deliberately don't configure email (single-user instances,
39//! evaluation environments). A startup failure would punish
40//! them for a non-essential capability.
41
42use std::env;
43
44/// TLS connection mode for an SMTP submission endpoint.
45///
46/// Auto-derived from `SMTP_PORT` if `SMTP_TLS_MODE` is not set:
47/// 465 → `Implicit`, 587 → `Starttls`, anything else →
48/// `Implicit` (modern default per upstream guidance).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum TlsMode {
51    /// Implicit TLS — TLS handshake before any SMTP traffic.
52    /// The standard "submissions" port is 465.
53    Implicit,
54    /// STARTTLS — connect plaintext, run EHLO, upgrade in
55    /// place. The standard "submission" port is 587.
56    Starttls,
57}
58
59impl TlsMode {
60    fn from_str_loose(s: &str) -> Option<Self> {
61        match s.trim().to_ascii_lowercase().as_str() {
62            "implicit" | "implicit_tls" | "smtps" | "tls" => Some(Self::Implicit),
63            "starttls" | "start_tls" | "submission" => Some(Self::Starttls),
64            _ => None,
65        }
66    }
67
68    /// Default mode from a port number, used when SMTP_TLS_MODE
69    /// is not set explicitly.
70    pub fn default_for_port(port: u16) -> Self {
71        match port {
72            587 => Self::Starttls,
73            // Including 465 and any non-standard port.
74            // Implicit TLS is the modern default; if an operator
75            // is using a non-standard port and STARTTLS, they
76            // should set SMTP_TLS_MODE explicitly.
77            _ => Self::Implicit,
78        }
79    }
80
81    pub fn as_str(self) -> &'static str {
82        match self {
83            Self::Implicit => "implicit",
84            Self::Starttls => "starttls",
85        }
86    }
87}
88
89/// SMTP transport configuration. `None` if any required env var
90/// is missing; the caller treats `None` as "email channel
91/// unavailable" and logs accordingly.
92#[derive(Debug, Clone)]
93pub struct SmtpConfig {
94    pub host: String,
95    pub port: u16,
96    pub tls_mode: TlsMode,
97    pub username: String,
98    pub password: String,
99    pub from_address: String,
100    pub from_name: Option<String>,
101}
102
103impl SmtpConfig {
104    /// Read from environment. Returns `None` if any required
105    /// variable is missing.
106    pub fn from_env() -> Option<Self> {
107        let host = env::var("SMTP_HOST").ok().filter(|s| !s.is_empty())?;
108        let username = env::var("SMTP_USER").ok().filter(|s| !s.is_empty())?;
109        let password = env::var("SMTP_PASSWORD").ok().filter(|s| !s.is_empty())?;
110        let from_address = env::var("SMTP_FROM_ADDRESS")
111            .ok()
112            .filter(|s| !s.is_empty())?;
113        let port = env::var("SMTP_PORT")
114            .ok()
115            .and_then(|s| s.parse::<u16>().ok())
116            .unwrap_or(465);
117        let tls_mode = env::var("SMTP_TLS_MODE")
118            .ok()
119            .and_then(|s| TlsMode::from_str_loose(&s))
120            .unwrap_or_else(|| TlsMode::default_for_port(port));
121        let from_name = env::var("SMTP_FROM_NAME").ok().filter(|s| !s.is_empty());
122
123        Some(SmtpConfig {
124            host,
125            port,
126            tls_mode,
127            username,
128            password,
129            from_address,
130            from_name,
131        })
132    }
133
134    /// Log the configuration status at startup. If `None`, log
135    /// a warning so an operator notices in dev/staging.
136    pub fn log_startup(config: Option<&SmtpConfig>) {
137        match config {
138            Some(c) => {
139                tracing::info!(
140                    host = %c.host,
141                    port = c.port,
142                    tls_mode = c.tls_mode.as_str(),
143                    from = %c.from_address,
144                    "SMTP configured; email channel ready"
145                );
146            }
147            None => {
148                tracing::warn!(
149                    "SMTP not configured (set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, \
150                     SMTP_FROM_ADDRESS to enable the email channel). The email \
151                     channel will fail at send time; in-app delivery continues \
152                     to work."
153                );
154            }
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn tls_mode_default_465_is_implicit() {
165        assert_eq!(TlsMode::default_for_port(465), TlsMode::Implicit);
166    }
167
168    #[test]
169    fn tls_mode_default_587_is_starttls() {
170        assert_eq!(TlsMode::default_for_port(587), TlsMode::Starttls);
171    }
172
173    #[test]
174    fn tls_mode_from_str_accepts_aliases() {
175        assert_eq!(TlsMode::from_str_loose("implicit"), Some(TlsMode::Implicit));
176        assert_eq!(TlsMode::from_str_loose("smtps"), Some(TlsMode::Implicit));
177        assert_eq!(TlsMode::from_str_loose("starttls"), Some(TlsMode::Starttls));
178        assert_eq!(TlsMode::from_str_loose("submission"), Some(TlsMode::Starttls));
179        assert_eq!(TlsMode::from_str_loose("garbage"), None);
180    }
181}