normalize email from

This commit is contained in:
Mateusz Gruszczyński
2026-07-27 18:13:08 +02:00
parent 2538e97fe2
commit 3e4cb910c2
2 changed files with 87 additions and 2 deletions
+1 -1
View File
@@ -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. 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 <no-reply@example.com>` 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 ## Diagnostics and logging
+86 -1
View File
@@ -1,3 +1,4 @@
use lettre::message::Mailbox;
use std::{collections::HashMap, env, net::IpAddr, path::Path}; use std::{collections::HashMap, env, net::IpAddr, path::Path};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -122,7 +123,9 @@ impl Config {
port: values.get("SMTP_PORT", "587").parse()?, port: values.get("SMTP_PORT", "587").parse()?,
username: values.get("SMTP_USERNAME", ""), username: values.get("SMTP_USERNAME", ""),
password: values.get("SMTP_PASSWORD", ""), 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")?, public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?,
}) })
} else { } else {
@@ -171,6 +174,9 @@ impl Config {
return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
} }
if let Some(smtp) = &self.smtp { if let Some(smtp) = &self.smtp {
smtp.from
.parse::<Mailbox>()
.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://")) { if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
return Err("PUBLIC_URL must start with http:// or https://".into()); return Err("PUBLIC_URL must start with http:// or https://".into());
} }
@@ -180,6 +186,37 @@ impl Config {
} }
fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Error>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("SMTP_FROM cannot be empty".into());
}
if trimmed.parse::<Mailbox>().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::<Mailbox>()
.map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?;
Ok(unquoted.to_owned())
}
const KNOWN_CONFIG_KEYS: &[&str] = &[ const KNOWN_CONFIG_KEYS: &[&str] = &[
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", "APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", "STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
@@ -346,3 +383,51 @@ fn parse_yaml_scalar(value: &str) -> Result<String, String> {
} }
Ok(value.to_owned()) 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 <rustpad@notes.example>".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_removes_matching_double_quotes() {
assert_eq!(
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_removes_matching_single_quotes() {
assert_eq!(
normalize_smtp_from(" 'RustPad <rustpad@notes.example>' ".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_preserves_valid_quoted_display_name() {
assert_eq!(
normalize_smtp_from("\"Rust, Pad\" <rustpad@notes.example>".to_owned()).unwrap(),
"\"Rust, Pad\" <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_rejects_unmatched_quotes() {
assert!(normalize_smtp_from("\"RustPad <rustpad@notes.example>".to_owned()).is_err());
}
#[test]
fn smtp_from_rejects_invalid_mailbox() {
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
}
}