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}