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)]
pub struct AttachmentSpec {
pub filename: String,
pub content_type: String,
pub data: String,
}
#[derive(Debug, Clone)]
pub struct ValidatedAttachment {
pub filename: String,
pub content_type: String,
pub decoded: Vec<u8>,
}
#[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<Recipients>,
pub body_html: Option<String>,
pub cc: Option<Recipients>,
pub attachments: Option<Vec<AttachmentSpec>>,
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: Vec<String>,
pub body_html: Option<String>,
pub cc: Vec<String>,
pub attachments: Vec<ValidatedAttachment>,
pub client_request_id: Option<String>,
}
pub fn validate_mail_request(
req: MailRequest,
config: &AppConfig,
auth: &AuthContext,
) -> Result<ValidatedMailRequest, AppError> {
if req.body_html.is_some() && !config.mail.allow_html_body {
return Err(AppError::FeatureDisabled("html body is not enabled on this server".into()));
}
if req.attachments.as_ref().map_or(false, |a| !a.is_empty()) && !config.mail.allow_attachments {
return Err(AppError::FeatureDisabled("attachments are not enabled on this server".into()));
}
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 cc: Vec<String> = if let Some(cc_recipients) = req.cc {
let cc_addrs = cc_recipients.0;
let total = to.len() + cc_addrs.len();
if total > config.mail.max_recipients {
return Err(AppError::Validation(format!(
"to + cc: too many recipients (max {})",
config.mail.max_recipients
)));
}
for addr in &cc_addrs {
validate_email_address(addr, "cc")?;
sanitize::reject_header_crlf("cc", addr)?;
check_recipient_domain_or_address(addr, config, auth)?;
}
cc_addrs
} else {
vec![]
};
let subject = validate_subject(&req.subject, mail_cfg.max_subject_chars)?;
let body = validate_body(&req.body, mail_cfg.max_body_bytes)?;
if let Some(ref html) = req.body_html {
if html.contains('\0') {
return Err(AppError::Validation("body_html: contains NUL character".into()));
}
if html.len() > mail_cfg.max_body_bytes {
return Err(AppError::Validation(format!(
"body_html: exceeds maximum of {} bytes",
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: Vec<String> = if let Some(recipients) = req.reply_to {
let addrs = recipients.0;
for addr in &addrs {
validate_email_address(addr, "reply_to")?;
sanitize::reject_header_crlf("reply_to", addr)?;
}
addrs
} else {
vec![]
};
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());
let attachments: Vec<ValidatedAttachment> = {
use base64::Engine as _;
let specs = req.attachments.unwrap_or_default();
if specs.len() > mail_cfg.max_attachments {
return Err(AppError::Validation(format!(
"attachments: too many (max {})", mail_cfg.max_attachments
)));
}
let mut validated = Vec::with_capacity(specs.len());
let mut total_decoded_bytes: usize = 0;
for (i, spec) in specs.into_iter().enumerate() {
if spec.filename.is_empty() || spec.filename.len() > 255 {
return Err(AppError::Validation("attachments[].filename: must be 1–255 chars".into()));
}
if spec.filename.contains('/') || spec.filename.contains('\\') || spec.filename.contains('\0') {
return Err(AppError::Validation("attachments[].filename: path separators not allowed".into()));
}
sanitize::reject_header_crlf("attachments[].filename", &spec.filename)?;
if spec.content_type.is_empty()
|| !spec.content_type.contains('/')
|| sanitize::contains_header_injection(&spec.content_type)
|| sanitize::contains_control_chars(&spec.content_type)
{
return Err(AppError::Validation(
"attachments[].content_type: invalid format or forbidden characters".into(),
));
}
let max_encoded = (mail_cfg.max_attachment_bytes * 4 / 3) + 8;
if spec.data.len() > max_encoded {
return Err(AppError::Validation(format!(
"attachments[{}].data: encoded size {} bytes exceeds limit", i, spec.data.len()
)));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(&spec.data)
.map_err(|_| AppError::Validation("attachments[].data: invalid base64".into()))?;
if decoded.len() > mail_cfg.max_attachment_bytes {
return Err(AppError::Validation(format!(
"attachments[].data: decoded size {} exceeds maximum {}",
decoded.len(), mail_cfg.max_attachment_bytes
)));
}
total_decoded_bytes += decoded.len();
if let Some(max_total) = mail_cfg.max_total_attachment_bytes {
if total_decoded_bytes > max_total {
return Err(AppError::Validation(format!(
"attachments: total decoded size {} exceeds aggregate limit {}",
total_decoded_bytes, max_total
)));
}
}
validated.push(ValidatedAttachment {
filename: spec.filename,
content_type: spec.content_type,
decoded,
});
}
validated
};
Ok(ValidatedMailRequest {
to,
subject,
body,
body_html: req.body_html,
from_name,
reply_to,
cc,
attachments,
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()))
}