use serde::Deserialize;
use crate::{
auth::AuthContext,
policy,
sanitize,
config::AppConfig,
error::AppError,
sanitize::{contains_control_chars, contains_header_injection},
};
#[derive(Debug, Clone)]
pub struct Recipients(pub Vec<String>);
impl<'de> serde::Deserialize<'de> for Recipients {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
match OneOrMany::deserialize(de)? {
OneOrMany::One(s) => Ok(Recipients(vec![s])),
OneOrMany::Many(v) => Ok(Recipients(v)),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MailRequest {
pub to: Recipients,
pub subject: String,
pub body: String,
pub from_name: Option<String>,
pub reply_to: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct ValidatedMailRequest {
pub to: Vec<String>,
pub subject: String,
pub body: String,
pub from_name: Option<String>,
pub reply_to: Option<String>,
pub client_request_id: Option<String>,
}
pub fn validate_mail_request(
req: MailRequest,
config: &AppConfig,
auth: &AuthContext,
) -> Result<ValidatedMailRequest, AppError> {
let mail_cfg = &config.mail;
{
let recipients = &req.to.0;
if recipients.is_empty() {
return Err(AppError::Validation("to: at least one recipient is required".into()));
}
if recipients.len() > config.mail.max_recipients {
return Err(AppError::Validation(format!(
"to: too many recipients (max {})",
config.mail.max_recipients
)));
}
for addr in recipients {
validate_email_address(addr, "to")?;
sanitize::reject_header_crlf("to", addr)?;
check_recipient_domain_or_address(addr, config, auth)?;
}
}
let to = req.to.0;
let subject = validate_subject(&req.subject, mail_cfg.max_subject_chars)?;
let body = validate_body(&req.body, mail_cfg.max_body_bytes)?;
let from_name = req
.from_name
.as_deref()
.map(|n| validate_display_name(n, "from_name"))
.transpose()?;
let reply_to = req
.reply_to
.as_deref()
.map(|a| validate_email_address(a, "reply_to"))
.transpose()?;
let client_request_id = req
.metadata
.as_ref()
.and_then(|m| m.get("request_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(ValidatedMailRequest {
to,
subject,
body,
from_name,
reply_to,
client_request_id,
})
}
fn validate_email_address(raw: &str, field: &str) -> Result<String, AppError> {
use lettre::address::Address;
if contains_header_injection(raw) {
return Err(AppError::Validation(format!(
"field `{field}` contains illegal line break"
)));
}
raw.parse::<Address>()
.map(|a| a.to_string())
.map_err(|_| AppError::Validation(format!("field `{field}` is not a valid email address")))
}
fn validate_subject(raw: &str, max_len: usize) -> Result<String, AppError> {
if raw.trim().is_empty() {
return Err(AppError::Validation(
"field `subject` must not be empty".into(),
));
}
if contains_header_injection(raw) {
return Err(AppError::Validation(
"field `subject` contains illegal line break".into(),
));
}
if contains_control_chars(raw) {
return Err(AppError::Validation(
"field `subject` contains illegal control character".into(),
));
}
if raw.chars().count() > max_len {
return Err(AppError::Validation(format!(
"field `subject` exceeds maximum length of {max_len} characters"
)));
}
Ok(raw.to_string())
}
fn validate_body(raw: &str, max_len: usize) -> Result<String, AppError> {
if raw.contains('\0') {
return Err(AppError::Validation(
"field `body` contains NUL byte".into(),
));
}
if raw.len() > max_len {
return Err(AppError::Validation(format!(
"field `body` exceeds maximum size of {max_len} bytes"
)));
}
Ok(raw.to_string())
}
fn validate_display_name(raw: &str, field: &str) -> Result<String, AppError> {
if contains_header_injection(raw) {
return Err(AppError::Validation(format!(
"field `{field}` contains illegal line break"
)));
}
Ok(raw.to_string())
}
fn check_recipient_domain_or_address(
addr: &str,
config: &AppConfig,
auth: &AuthContext,
) -> Result<(), AppError> {
if let Some(key_cfg) = config.security.api_keys.iter().find(|k| k.id == auth.key_id) {
if !policy::address_permitted_for_key(key_cfg, addr) {
return Err(AppError::Validation(
"to: recipient is not permitted for this API key".into(),
));
}
}
check_recipient_domain(addr, config, auth)
}
fn check_recipient_domain(
to: &str,
config: &AppConfig,
auth: &AuthContext,
) -> Result<(), AppError> {
let domain = extract_domain(to)?;
if !config.mail.allowed_recipient_domains.is_empty()
&& !config
.mail
.allowed_recipient_domains
.iter()
.any(|d| d.eq_ignore_ascii_case(&domain))
{
return Err(AppError::Validation(format!(
"recipient domain `{domain}` is not permitted"
)));
}
let key_cfg = config
.security
.api_keys
.iter()
.find(|k| k.id == auth.key_id);
if let Some(key) = key_cfg {
if !key.allowed_recipient_domains.is_empty()
&& !key
.allowed_recipient_domains
.iter()
.any(|d| d.eq_ignore_ascii_case(&domain))
{
return Err(AppError::Validation(format!(
"recipient domain `{domain}` is not permitted for this API key"
)));
}
}
Ok(())
}
fn extract_domain(email: &str) -> Result<String, AppError> {
email
.rsplit_once('@')
.map(|(_, d)| d.to_lowercase())
.ok_or_else(|| AppError::Validation("could not extract domain from email address".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::AuthContext,
config::{
ApiKeyConfig, AppConfig, LoggingConfig, MailConfig, RateLimitConfig, SecretString,
SecurityConfig, ServerConfig, SmtpConfig,
},
};
use std::net::IpAddr;
fn make_auth(key_id: &str) -> AuthContext {
AuthContext {
key_id: key_id.to_string(),
client_ip: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
key_rate_limit_per_min: None,
key_burst: 0,
}
}
fn minimal_config() -> AppConfig {
AppConfig {
server: ServerConfig {
bind_address: "127.0.0.1:8080".into(),
max_request_body_bytes: 65536,
request_timeout_seconds: 30,
shutdown_timeout_seconds: 30,
concurrency_limit: 0,
},
security: SecurityConfig {
require_auth: true,
trust_proxy_headers: false,
trusted_source_cidrs: vec![],
allowed_source_cidrs: vec![],
api_keys: vec![ApiKeyConfig {
id: "test-key".into(),
secret: SecretString::new("tok"),
enabled: true,
description: None,
allowed_recipient_domains: vec![],
allowed_recipients: vec![],
rate_limit_per_min: None,
burst: 0,
}],
},
mail: MailConfig {
default_from: "relay@example.com".into(),
default_from_name: None,
allowed_recipient_domains: vec![],
max_subject_chars: 200,
max_body_bytes: 1_000_000,
max_recipients: 10,
},
smtp: SmtpConfig {
mode: "smtp".into(),
host: "127.0.0.1".into(),
port: 25,
connect_timeout_seconds: 5,
submission_timeout_seconds: 30,
auth_user: None,
auth_password: None,
pipe_command: "/usr/sbin/sendmail".into(),
},
rate_limit: RateLimitConfig {
global_per_min: 60,
per_ip_per_min: 20,
per_key_per_min: 30,
global_burst: 5,
per_ip_burst: 5,
per_key_burst: 5,
burst_size: 0,
ip_table_size: 100,
},
logging: LoggingConfig {
format: "text".into(),
level: "info".into(),
mask_recipient: false,
},
}
}
fn minimal_request() -> MailRequest {
MailRequest {
to: crate::validation::Recipients(vec!["user@example.com".into()]),
subject: "Hello".into(),
body: "Test body".into(),
from_name: None,
reply_to: None,
metadata: None,
}
}
#[test]
fn valid_request_passes() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = minimal_request();
assert!(validate_mail_request(req, &cfg, &auth).is_ok());
}
#[test]
fn invalid_email_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
to: crate::validation::Recipients(vec!["not-an-email".into()]),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn crlf_in_subject_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
subject: "Hello\r\nBcc: evil@x.com".into(),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn empty_subject_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
subject: " ".into(),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn oversized_subject_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
subject: "a".repeat(201),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn nul_in_body_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
body: "Hello\0world".into(),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn crlf_in_from_name_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
from_name: Some("Evil\r\nBcc: attacker@evil.com".into()),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn disallowed_domain_rejected() {
let mut cfg = minimal_config();
cfg.mail.allowed_recipient_domains = vec!["allowed.com".into()];
let auth = make_auth("test-key");
let req = MailRequest {
to: crate::validation::Recipients(vec!["user@other.com".into()]),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn allowed_domain_passes() {
let mut cfg = minimal_config();
cfg.mail.allowed_recipient_domains = vec!["example.com".into()];
let auth = make_auth("test-key");
let req = minimal_request(); assert!(validate_mail_request(req, &cfg, &auth).is_ok());
}
#[test]
fn per_key_domain_restriction_works() {
let mut cfg = minimal_config();
cfg.security.api_keys[0].allowed_recipient_domains = vec!["allowed.com".into()];
let auth = make_auth("test-key");
let req = minimal_request(); assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn metadata_client_request_id_extracted() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
metadata: Some(serde_json::json!({"request_id": "client-123"})),
..minimal_request()
};
let v = validate_mail_request(req, &cfg, &auth).unwrap();
assert_eq!(v.client_request_id.as_deref(), Some("client-123"));
}
#[test]
fn crlf_in_reply_to_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
for bad in &[
"user@example.com
Bcc: evil@evil.com",
"user@example.com
X-Header: injected",
] {
let req = MailRequest {
reply_to: Some(bad.to_string()),
..minimal_request()
};
assert!(
matches!(validate_mail_request(req, &cfg, &auth), Err(AppError::Validation(_))),
"expected Validation error for reply_to={bad:?}"
);
}
}
#[test]
fn crlf_in_to_rejected() {
let cfg = minimal_config();
let auth = make_auth("test-key");
let req = MailRequest {
to: crate::validation::Recipients(vec!["user@example.com\nBcc: attacker@evil.com".to_string()]),
..minimal_request()
};
assert!(matches!(
validate_mail_request(req, &cfg, &auth),
Err(AppError::Validation(_))
));
}
#[test]
fn secret_string_redacted_in_debug() {
use crate::config::SecretString;
let s = SecretString::new("super-secret-token-value");
let debug = format!("{s:?}");
assert!(
!debug.contains("super-secret-token-value"),
"SecretString Debug must not expose secret; got: {debug}"
);
}
}