From 3e4cb910c2904ba00dc0dc41a926a67ea91416f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 27 Jul 2026 18:13:08 +0200 Subject: [PATCH] normalize email from --- README.md | 2 +- src/config.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce33041..8c3b82d 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Migrations are stored in `migrations/sqlite`, `migrations/postgres`, and `migrat Nicknames can be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections. -Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. Reset links expire after 30 minutes and can be used only once. +Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad ` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. Reset links expire after 30 minutes and can be used only once. ## Diagnostics and logging diff --git a/src/config.rs b/src/config.rs index 584d030..e94fad6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use lettre::message::Mailbox; use std::{collections::HashMap, env, net::IpAddr, path::Path}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -122,7 +123,9 @@ impl Config { port: values.get("SMTP_PORT", "587").parse()?, username: values.get("SMTP_USERNAME", ""), password: values.get("SMTP_PASSWORD", ""), - from: values.required("SMTP_FROM", "SMTP_HOST is set")?, + from: normalize_smtp_from( + values.required("SMTP_FROM", "SMTP_HOST is set")?, + )?, public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?, }) } else { @@ -171,6 +174,9 @@ impl Config { return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); } if let Some(smtp) = &self.smtp { + smtp.from + .parse::() + .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) { return Err("PUBLIC_URL must start with http:// or https://".into()); } @@ -180,6 +186,37 @@ impl Config { } + +fn normalize_smtp_from(value: String) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("SMTP_FROM cannot be empty".into()); + } + + if trimmed.parse::().is_ok() { + return Ok(trimmed.to_owned()); + } + + let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) { + (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => { + trimmed[1..trimmed.len() - 1].trim() + } + _ => { + return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into()); + } + }; + + if unquoted.is_empty() { + return Err("SMTP_FROM cannot be empty".into()); + } + + unquoted + .parse::() + .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; + + Ok(unquoted.to_owned()) +} + const KNOWN_CONFIG_KEYS: &[&str] = &[ "APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", "STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", @@ -346,3 +383,51 @@ fn parse_yaml_scalar(value: &str) -> Result { } Ok(value.to_owned()) } + + +#[cfg(test)] +mod tests { + use super::normalize_smtp_from; + + #[test] + fn smtp_from_accepts_unquoted_value() { + assert_eq!( + normalize_smtp_from("RustPad ".to_owned()).unwrap(), + "RustPad " + ); + } + + #[test] + fn smtp_from_removes_matching_double_quotes() { + assert_eq!( + normalize_smtp_from(" \"RustPad \" ".to_owned()).unwrap(), + "RustPad " + ); + } + + #[test] + fn smtp_from_removes_matching_single_quotes() { + assert_eq!( + normalize_smtp_from(" 'RustPad ' ".to_owned()).unwrap(), + "RustPad " + ); + } + + #[test] + fn smtp_from_preserves_valid_quoted_display_name() { + assert_eq!( + normalize_smtp_from("\"Rust, Pad\" ".to_owned()).unwrap(), + "\"Rust, Pad\" " + ); + } + + #[test] + fn smtp_from_rejects_unmatched_quotes() { + assert!(normalize_smtp_from("\"RustPad ".to_owned()).is_err()); + } + + #[test] + fn smtp_from_rejects_invalid_mailbox() { + assert!(normalize_smtp_from("RustPad".to_owned()).is_err()); + } +}