···5566static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
7788+const REMOVED_ENV_VARS: &[(&str, &str)] = &[(
99+ "SENDMAIL_PATH",
1010+ "the sendmail-binary transport was replaced with native SMTP. \
1111+ Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \
1212+ deliver directly via recipient MX records. See example.toml for the full \
1313+ MAIL_* surface.",
1414+)];
1515+816/// Errors discovered during configuration validation.
917#[derive(Debug)]
1018pub struct ConfigError {
···162170 pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> {
163171 let mut errors = Vec::new();
164172173173+ // -- removed config ---------------------------------------------------
174174+ errors.extend(
175175+ REMOVED_ENV_VARS
176176+ .iter()
177177+ .filter(|(var, _)| std::env::var_os(var).is_some())
178178+ .map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")),
179179+ );
180180+165181 // -- secrets ----------------------------------------------------------
166182 if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) {
167183 if let Some(ref s) = self.secrets.jwt_secret {
···205221 errors.push(
206222 "secrets.master_key (MASTER_KEY) is required in production \
207223 (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
224224+ .to_string(),
225225+ );
226226+ }
227227+ }
228228+229229+ // -- email smarthost --------------------------------------------------
230230+ match self.email.smarthost.tls.to_ascii_lowercase().as_str() {
231231+ "implicit" | "starttls" => {}
232232+ "none" => {
233233+ if self.email.smarthost.password.is_some() {
234234+ errors.push(
235235+ "email.smarthost.tls = \"none\" with email.smarthost.password set \
236236+ would transmit credentials in plaintext; use \"starttls\" or \"implicit\""
237237+ .to_string(),
238238+ );
239239+ }
240240+ }
241241+ other => errors.push(format!(
242242+ "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\""
243243+ )),
244244+ }
245245+246246+ let smarthost_host_set = self
247247+ .email
248248+ .smarthost
249249+ .host
250250+ .as_deref()
251251+ .is_some_and(|h| !h.is_empty());
252252+ let username_set = self.email.smarthost.username.is_some();
253253+ let password_set = self.email.smarthost.password.is_some();
254254+ if !smarthost_host_set && (username_set || password_set) {
255255+ errors.push(
256256+ "email.smarthost.username or email.smarthost.password is set but \
257257+ email.smarthost.host is empty; credentials would be silently ignored"
258258+ .to_string(),
259259+ );
260260+ }
261261+ if smarthost_host_set && username_set != password_set {
262262+ errors.push(
263263+ "email.smarthost.username and email.smarthost.password must both be set or \
264264+ both unset; otherwise authentication would silently degrade to anonymous"
265265+ .to_string(),
266266+ );
267267+ }
268268+269269+ if self.email.smarthost.command_timeout_secs == 0 {
270270+ errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string());
271271+ }
272272+ if self.email.smarthost.total_timeout_secs == 0 {
273273+ errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string());
274274+ }
275275+ if self.email.smarthost.pool_size == 0 {
276276+ errors.push("email.smarthost.pool_size must be at least 1".to_string());
277277+ }
278278+279279+ if self.email.direct_mx.max_concurrent_sends == 0 {
280280+ errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string());
281281+ }
282282+ if self.email.direct_mx.command_timeout_secs == 0 {
283283+ errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string());
284284+ }
285285+ if self.email.direct_mx.total_timeout_secs == 0 {
286286+ errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string());
287287+ }
288288+289289+ let dkim_set = self.email.dkim.selector.is_some()
290290+ || self.email.dkim.domain.is_some()
291291+ || self.email.dkim.private_key_path.is_some();
292292+ if dkim_set {
293293+ if self.email.dkim.selector.is_none() {
294294+ errors
295295+ .push("email.dkim.selector is required when any DKIM field is set".to_string());
296296+ }
297297+ if self.email.dkim.domain.is_none() {
298298+ errors.push("email.dkim.domain is required when any DKIM field is set".to_string());
299299+ }
300300+ if self.email.dkim.private_key_path.is_none() {
301301+ errors.push(
302302+ "email.dkim.private_key_path is required when any DKIM field is set"
208303 .to_string(),
209304 );
210305 }
···754849 #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
755850 pub from_name: String,
756851757757- /// Path to the `sendmail` binary.
758758- #[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
759759- pub sendmail_path: String,
852852+ /// HELO/EHLO name announced to remote SMTP servers. Applies to both
853853+ /// smarthost and direct-MX modes. Defaults to the server hostname.
854854+ #[config(env = "MAIL_HELO_NAME")]
855855+ pub helo_name: Option<String>,
856856+857857+ #[config(nested)]
858858+ pub smarthost: SmarthostConfig,
859859+860860+ #[config(nested)]
861861+ pub direct_mx: DirectMxConfig,
862862+863863+ #[config(nested)]
864864+ pub dkim: DkimConfig,
865865+}
866866+867867+#[derive(Debug, Config)]
868868+pub struct SmarthostConfig {
869869+ /// SMTP relay host. When set, mail is delivered through this host
870870+ /// instead of resolving recipient MX records directly.
871871+ #[config(env = "MAIL_SMARTHOST_HOST")]
872872+ pub host: Option<String>,
873873+874874+ /// SMTP relay port.
875875+ #[config(env = "MAIL_SMARTHOST_PORT", default = 587)]
876876+ pub port: u16,
877877+878878+ /// SMTP authentication username.
879879+ #[config(env = "MAIL_SMARTHOST_USERNAME")]
880880+ pub username: Option<String>,
881881+882882+ /// SMTP authentication password.
883883+ #[config(env = "MAIL_SMARTHOST_PASSWORD")]
884884+ pub password: Option<String>,
885885+886886+ /// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none"
887887+ /// alongside a password is rejected at startup to prevent transmitting
888888+ /// credentials in plaintext.
889889+ #[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")]
890890+ pub tls: String,
891891+892892+ /// Max size of the connection pool.
893893+ #[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)]
894894+ pub pool_size: u32,
895895+896896+ /// Per-command SMTP timeout in seconds. Bounds the security handshake.
897897+ #[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)]
898898+ pub command_timeout_secs: u64,
899899+900900+ /// Total per-message timeout in seconds. Wraps the entire send so a
901901+ /// stuck relay cannot stall the comms queue.
902902+ #[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
903903+ pub total_timeout_secs: u64,
904904+}
905905+906906+#[derive(Debug, Config)]
907907+pub struct DirectMxConfig {
908908+ /// Per-command SMTP timeout in seconds.
909909+ #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)]
910910+ pub command_timeout_secs: u64,
911911+912912+ /// Total per-message timeout across all MX attempts in seconds.
913913+ #[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)]
914914+ pub total_timeout_secs: u64,
915915+916916+ /// Max number of concurrent direct-MX sends. Limits the load placed
917917+ /// on any single recipient MX during a backlog drain.
918918+ #[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)]
919919+ pub max_concurrent_sends: usize,
920920+921921+ /// Require STARTTLS on every MX hop. When false, TLS is
922922+ /// attempted opportunistically and the session falls back to plaintext
923923+ /// if the remote does not advertise STARTTLS. Set true to refuse
924924+ /// plaintext delivery, at the cost of failing sends to MX hosts that
925925+ /// do not support TLS.
926926+ #[config(env = "MAIL_REQUIRE_TLS", default = false)]
927927+ pub require_tls: bool,
928928+}
929929+930930+#[derive(Debug, Config)]
931931+pub struct DkimConfig {
932932+ /// DKIM selector. When unset, outgoing mail is not signed.
933933+ #[config(env = "MAIL_DKIM_SELECTOR")]
934934+ pub selector: Option<String>,
935935+936936+ /// DKIM signing domain.
937937+ #[config(env = "MAIL_DKIM_DOMAIN")]
938938+ pub domain: Option<String>,
939939+940940+ /// Path to the DKIM private key in PEM format. Supports RSA and
941941+ /// Ed25519 keys.
942942+ #[config(env = "MAIL_DKIM_KEY_PATH")]
943943+ pub private_key_path: Option<String>,
760944}
761945762946#[derive(Debug, Config)]
···11961380pub fn template() -> String {
11971381 confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())
11981382}
13831383+13841384+#[cfg(test)]
13851385+mod tests {
13861386+ use super::*;
13871387+13881388+ fn seed_required_env() {
13891389+ let required = [
13901390+ ("PDS_HOSTNAME", "test.local"),
13911391+ ("DATABASE_URL", "postgres://localhost/test"),
13921392+ ("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"),
13931393+ ("INVITE_CODE_REQUIRED", "false"),
13941394+ ("ENABLE_PDS_HOSTED_DID_WEB", "true"),
13951395+ ("TRANQUIL_LEXICON_OFFLINE", "1"),
13961396+ ];
13971397+ required
13981398+ .iter()
13991399+ .filter(|(k, _)| std::env::var_os(k).is_none())
14001400+ .for_each(|(k, v)| unsafe { std::env::set_var(k, v) });
14011401+ }
14021402+14031403+ #[test]
14041404+ fn serial_validate_rejects_legacy_sendmail_path() {
14051405+ seed_required_env();
14061406+ unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") };
14071407+ let config = TranquilConfig::builder()
14081408+ .env()
14091409+ .load()
14101410+ .expect("load fresh config");
14111411+ let result = config.validate(true);
14121412+ unsafe { std::env::remove_var("SENDMAIL_PATH") };
14131413+14141414+ let err = result.expect_err("validate must reject SENDMAIL_PATH");
14151415+ let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH"));
14161416+ assert!(
14171417+ mentions_sendmail,
14181418+ "errors did not mention SENDMAIL_PATH: {:?}",
14191419+ err.errors
14201420+ );
14211421+ }
14221422+14231423+ #[test]
14241424+ fn serial_validate_passes_when_no_legacy_env_set() {
14251425+ seed_required_env();
14261426+ unsafe { std::env::remove_var("SENDMAIL_PATH") };
14271427+ let config = TranquilConfig::builder()
14281428+ .env()
14291429+ .load()
14301430+ .expect("load fresh config");
14311431+ let result = config.validate(true);
14321432+ let leaked_legacy = result
14331433+ .as_ref()
14341434+ .err()
14351435+ .map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH")))
14361436+ .unwrap_or(false);
14371437+ assert!(
14381438+ !leaked_legacy,
14391439+ "validate spuriously flagged SENDMAIL_PATH when unset: {:?}",
14401440+ result
14411441+ );
14421442+ }
14431443+}
+109-4
example.toml
···373373# Default value: "Tranquil PDS"
374374#from_name = "Tranquil PDS"
375375376376-# Path to the `sendmail` binary.
376376+# HELO/EHLO name announced to remote SMTP servers. Applies to both
377377+# smarthost and direct-MX modes. Defaults to the server hostname.
378378+#
379379+# Can also be specified via environment variable `MAIL_HELO_NAME`.
380380+#helo_name =
381381+382382+[email.smarthost]
383383+# SMTP relay host. When set, mail is delivered through this host
384384+# instead of resolving recipient MX records directly.
385385+#
386386+# Can also be specified via environment variable `MAIL_SMARTHOST_HOST`.
387387+#host =
388388+389389+# SMTP relay port.
390390+#
391391+# Can also be specified via environment variable `MAIL_SMARTHOST_PORT`.
392392+#
393393+# Default value: 587
394394+#port = 587
395395+396396+# SMTP authentication username.
397397+#
398398+# Can also be specified via environment variable `MAIL_SMARTHOST_USERNAME`.
399399+#username =
400400+401401+# SMTP authentication password.
402402+#
403403+# Can also be specified via environment variable `MAIL_SMARTHOST_PASSWORD`.
404404+#password =
405405+406406+# TLS mode. Valid values: "implicit", "starttls", "none". Setting "none"
407407+# alongside a password is rejected at startup to prevent transmitting
408408+# credentials in plaintext.
409409+#
410410+# Can also be specified via environment variable `MAIL_SMARTHOST_TLS`.
411411+#
412412+# Default value: "starttls"
413413+#tls = "starttls"
414414+415415+# Max size of the connection pool.
416416+#
417417+# Can also be specified via environment variable `MAIL_SMARTHOST_POOL_SIZE`.
418418+#
419419+# Default value: 4
420420+#pool_size = 4
421421+422422+# Per-command SMTP timeout in seconds. Bounds the security handshake.
423423+#
424424+# Can also be specified via environment variable `MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS`.
425425+#
426426+# Default value: 30
427427+#command_timeout_secs = 30
428428+429429+# Total per-message timeout in seconds. Wraps the entire send so a
430430+# stuck relay cannot stall the comms queue.
431431+#
432432+# Can also be specified via environment variable `MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS`.
377433#
378378-# Can also be specified via environment variable `SENDMAIL_PATH`.
434434+# Default value: 60
435435+#total_timeout_secs = 60
436436+437437+[email.direct_mx]
438438+# Per-command SMTP timeout in seconds.
379439#
380380-# Default value: "/usr/sbin/sendmail"
381381-#sendmail_path = "/usr/sbin/sendmail"
440440+# Can also be specified via environment variable `MAIL_COMMAND_TIMEOUT_SECS`.
441441+#
442442+# Default value: 30
443443+#command_timeout_secs = 30
444444+445445+# Total per-message timeout across all MX attempts in seconds.
446446+#
447447+# Can also be specified via environment variable `MAIL_TOTAL_TIMEOUT_SECS`.
448448+#
449449+# Default value: 60
450450+#total_timeout_secs = 60
451451+452452+# Max number of concurrent direct-MX sends. Limits the load placed
453453+# on any single recipient MX during a backlog drain.
454454+#
455455+# Can also be specified via environment variable `MAIL_MAX_CONCURRENT_SENDS`.
456456+#
457457+# Default value: 8
458458+#max_concurrent_sends = 8
459459+460460+# Require STARTTLS on every MX hop. When false, TLS is
461461+# attempted opportunistically and the session falls back to plaintext
462462+# if the remote does not advertise STARTTLS. Set true to refuse
463463+# plaintext delivery, at the cost of failing sends to MX hosts that
464464+# do not support TLS.
465465+#
466466+# Can also be specified via environment variable `MAIL_REQUIRE_TLS`.
467467+#
468468+# Default value: false
469469+#require_tls = false
470470+471471+[email.dkim]
472472+# DKIM selector. When unset, outgoing mail is not signed.
473473+#
474474+# Can also be specified via environment variable `MAIL_DKIM_SELECTOR`.
475475+#selector =
476476+477477+# DKIM signing domain.
478478+#
479479+# Can also be specified via environment variable `MAIL_DKIM_DOMAIN`.
480480+#domain =
481481+482482+# Path to the DKIM private key in PEM format. Supports RSA and
483483+# Ed25519 keys.
484484+#
485485+# Can also be specified via environment variable `MAIL_DKIM_KEY_PATH`.
486486+#private_key_path =
382487383488[discord]
384489# Discord bot token. When unset, Discord integration is disabled.