feat(rust): implement mailer-core and mailer-security crates with CLI
Rust migration Phase 1 — implements real functionality in the previously stubbed mailer-core and mailer-security crates (38 passing tests). mailer-core: Email/EmailAddress/Attachment types, RFC 5322 MIME builder, email format validation with scoring, bounce detection (14 types, 40+ regex patterns), DSN status parsing, retry delay calculation. mailer-security: DKIM signing (RSA-SHA256) and verification, SPF checking, DMARC verification with public suffix list, DNSBL IP reputation checking (10 default servers, parallel queries), all powered by mail-auth 0.7. mailer-bin: Full CLI with validate/bounce/check-ip/verify-email/dkim-sign subcommands plus --management mode for smartrust JSON-over-stdin/stdout IPC.
This commit is contained in:
485
rust/crates/mailer-core/src/bounce.rs
Normal file
485
rust/crates/mailer-core/src/bounce.rs
Normal file
@@ -0,0 +1,485 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Type of email bounce.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BounceType {
|
||||
// Hard bounces
|
||||
InvalidRecipient,
|
||||
DomainNotFound,
|
||||
MailboxFull,
|
||||
MailboxInactive,
|
||||
Blocked,
|
||||
SpamRelated,
|
||||
PolicyRelated,
|
||||
// Soft bounces
|
||||
ServerUnavailable,
|
||||
TemporaryFailure,
|
||||
QuotaExceeded,
|
||||
NetworkError,
|
||||
Timeout,
|
||||
// Special
|
||||
AutoResponse,
|
||||
ChallengeResponse,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Broad category of a bounce.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BounceCategory {
|
||||
Hard,
|
||||
Soft,
|
||||
AutoResponse,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BounceType {
|
||||
/// Get the category for this bounce type.
|
||||
pub fn category(&self) -> BounceCategory {
|
||||
match self {
|
||||
BounceType::InvalidRecipient
|
||||
| BounceType::DomainNotFound
|
||||
| BounceType::MailboxFull
|
||||
| BounceType::MailboxInactive
|
||||
| BounceType::Blocked
|
||||
| BounceType::SpamRelated
|
||||
| BounceType::PolicyRelated => BounceCategory::Hard,
|
||||
|
||||
BounceType::ServerUnavailable
|
||||
| BounceType::TemporaryFailure
|
||||
| BounceType::QuotaExceeded
|
||||
| BounceType::NetworkError
|
||||
| BounceType::Timeout => BounceCategory::Soft,
|
||||
|
||||
BounceType::AutoResponse | BounceType::ChallengeResponse => {
|
||||
BounceCategory::AutoResponse
|
||||
}
|
||||
|
||||
BounceType::Unknown => BounceCategory::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of bounce detection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BounceDetection {
|
||||
pub bounce_type: BounceType,
|
||||
pub category: BounceCategory,
|
||||
}
|
||||
|
||||
/// Pattern set for a bounce type: compiled regexes for matching against SMTP responses.
|
||||
struct BouncePatterns {
|
||||
bounce_type: BounceType,
|
||||
patterns: Vec<Regex>,
|
||||
}
|
||||
|
||||
/// All bounce detection patterns, compiled once.
|
||||
static BOUNCE_PATTERNS: LazyLock<Vec<BouncePatterns>> = LazyLock::new(|| {
|
||||
vec![
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::InvalidRecipient,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)no such user",
|
||||
r"(?i)user unknown",
|
||||
r"(?i)does not exist",
|
||||
r"(?i)invalid recipient",
|
||||
r"(?i)unknown recipient",
|
||||
r"(?i)no mailbox",
|
||||
r"(?i)user not found",
|
||||
r"(?i)recipient address rejected",
|
||||
r"(?i)550 5\.1\.1",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::DomainNotFound,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)domain not found",
|
||||
r"(?i)unknown domain",
|
||||
r"(?i)no such domain",
|
||||
r"(?i)host not found",
|
||||
r"(?i)domain invalid",
|
||||
r"(?i)550 5\.1\.2",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::MailboxFull,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)mailbox full",
|
||||
r"(?i)over quota",
|
||||
r"(?i)quota exceeded",
|
||||
r"(?i)552 5\.2\.2",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::MailboxInactive,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)mailbox disabled",
|
||||
r"(?i)mailbox inactive",
|
||||
r"(?i)account disabled",
|
||||
r"(?i)mailbox not active",
|
||||
r"(?i)account suspended",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::Blocked,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)blocked",
|
||||
r"(?i)rejected",
|
||||
r"(?i)denied",
|
||||
r"(?i)blacklisted",
|
||||
r"(?i)prohibited",
|
||||
r"(?i)refused",
|
||||
r"(?i)550 5\.7\.",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::SpamRelated,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)spam",
|
||||
r"(?i)bulk mail",
|
||||
r"(?i)content rejected",
|
||||
r"(?i)message rejected",
|
||||
r"(?i)550 5\.7\.1",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::ServerUnavailable,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)server unavailable",
|
||||
r"(?i)service unavailable",
|
||||
r"(?i)try again later",
|
||||
r"(?i)try later",
|
||||
r"(?i)451 4\.3\.",
|
||||
r"(?i)421 4\.3\.",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::TemporaryFailure,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)temporary failure",
|
||||
r"(?i)temporary error",
|
||||
r"(?i)temporary problem",
|
||||
r"(?i)try again",
|
||||
r"(?i)451 4\.",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::QuotaExceeded,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)quota temporarily exceeded",
|
||||
r"(?i)mailbox temporarily full",
|
||||
r"(?i)452 4\.2\.2",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::NetworkError,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)network error",
|
||||
r"(?i)connection error",
|
||||
r"(?i)connection timed out",
|
||||
r"(?i)routing error",
|
||||
r"(?i)421 4\.4\.",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::Timeout,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)timed out",
|
||||
r"(?i)timeout",
|
||||
r"(?i)450 4\.4\.2",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::AutoResponse,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)auto[- ]reply",
|
||||
r"(?i)auto[- ]response",
|
||||
r"(?i)vacation",
|
||||
r"(?i)out of office",
|
||||
r"(?i)away from office",
|
||||
r"(?i)on vacation",
|
||||
r"(?i)automatic reply",
|
||||
]),
|
||||
},
|
||||
BouncePatterns {
|
||||
bounce_type: BounceType::ChallengeResponse,
|
||||
patterns: compile_patterns(&[
|
||||
r"(?i)challenge[- ]response",
|
||||
r"(?i)verify your email",
|
||||
r"(?i)confirm your email",
|
||||
r"(?i)email verification",
|
||||
]),
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
/// Regex for detecting bounce email subjects.
|
||||
static BOUNCE_SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)mail delivery|delivery (?:failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem")
|
||||
.expect("invalid bounce subject regex")
|
||||
});
|
||||
|
||||
/// Regex for extracting recipient from bounce messages.
|
||||
static BOUNCE_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?")
|
||||
.expect("invalid bounce recipient regex")
|
||||
});
|
||||
|
||||
/// Regex for extracting diagnostic code.
|
||||
static DIAGNOSTIC_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)diagnostic(?:-|\s+)code:\s*(.+)")
|
||||
.expect("invalid diagnostic code regex")
|
||||
});
|
||||
|
||||
/// Regex for extracting status code.
|
||||
static STATUS_CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)status(?:-|\s+)code:\s*([0-9.]+)")
|
||||
.expect("invalid status code regex")
|
||||
});
|
||||
|
||||
/// Regex for DSN original-recipient.
|
||||
static DSN_ORIGINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)original-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
|
||||
.expect("invalid DSN original-recipient regex")
|
||||
});
|
||||
|
||||
/// Regex for DSN final-recipient.
|
||||
static DSN_FINAL_RECIPIENT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)final-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})")
|
||||
.expect("invalid DSN final-recipient regex")
|
||||
});
|
||||
|
||||
fn compile_patterns(patterns: &[&str]) -> Vec<Regex> {
|
||||
patterns
|
||||
.iter()
|
||||
.map(|p| Regex::new(p).expect("invalid bounce pattern regex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect bounce type from an SMTP response, diagnostic code, or status code.
|
||||
pub fn detect_bounce_type(
|
||||
smtp_response: Option<&str>,
|
||||
diagnostic_code: Option<&str>,
|
||||
status_code: Option<&str>,
|
||||
) -> BounceDetection {
|
||||
// Check all text sources against patterns
|
||||
let texts: Vec<&str> = [smtp_response, diagnostic_code, status_code]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
for bp in BOUNCE_PATTERNS.iter() {
|
||||
for text in &texts {
|
||||
for pattern in &bp.patterns {
|
||||
if pattern.is_match(text) {
|
||||
return BounceDetection {
|
||||
bounce_type: bp.bounce_type,
|
||||
category: bp.bounce_type.category(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: parse DSN status code (class.subject.detail)
|
||||
if let Some(code) = status_code {
|
||||
if let Some(detection) = parse_dsn_status(code) {
|
||||
return detection;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find DSN code in SMTP response
|
||||
if let Some(resp) = smtp_response {
|
||||
if let Some(code) = STATUS_CODE_RE.captures(resp).and_then(|c| c.get(1)) {
|
||||
if let Some(detection) = parse_dsn_status(code.as_str()) {
|
||||
return detection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BounceDetection {
|
||||
bounce_type: BounceType::Unknown,
|
||||
category: BounceCategory::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a DSN enhanced status code like "5.1.1" or "4.2.2".
|
||||
fn parse_dsn_status(code: &str) -> Option<BounceDetection> {
|
||||
let parts: Vec<&str> = code.split('.').collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let class: u8 = parts[0].parse().ok()?;
|
||||
let subject: u8 = parts[1].parse().ok()?;
|
||||
|
||||
let bounce_type = match (class, subject) {
|
||||
(5, 1) => BounceType::InvalidRecipient,
|
||||
(5, 2) => BounceType::MailboxFull,
|
||||
(5, 7) => BounceType::Blocked,
|
||||
(5, _) => BounceType::PolicyRelated,
|
||||
(4, 2) => BounceType::QuotaExceeded,
|
||||
(4, 3) => BounceType::ServerUnavailable,
|
||||
(4, 4) => BounceType::NetworkError,
|
||||
(4, _) => BounceType::TemporaryFailure,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(BounceDetection {
|
||||
category: bounce_type.category(),
|
||||
bounce_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a subject line looks like a bounce notification.
|
||||
pub fn is_bounce_subject(subject: &str) -> bool {
|
||||
BOUNCE_SUBJECT_RE.is_match(subject)
|
||||
}
|
||||
|
||||
/// Extract the bounced recipient email from a bounce message body.
|
||||
pub fn extract_bounce_recipient(body: &str) -> Option<String> {
|
||||
BOUNCE_RECIPIENT_RE
|
||||
.captures(body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.or_else(|| {
|
||||
DSN_FINAL_RECIPIENT_RE
|
||||
.captures(body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
DSN_ORIGINAL_RECIPIENT_RE
|
||||
.captures(body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the diagnostic code from a bounce message body.
|
||||
pub fn extract_diagnostic_code(body: &str) -> Option<String> {
|
||||
DIAGNOSTIC_CODE_RE
|
||||
.captures(body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract the status code from a bounce message body.
|
||||
pub fn extract_status_code(body: &str) -> Option<String> {
|
||||
STATUS_CODE_RE
|
||||
.captures(body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
}
|
||||
|
||||
/// Calculate retry delay using exponential backoff.
|
||||
///
|
||||
/// * `retry_count` - Number of retries so far (0-based)
|
||||
/// * `initial_delay_ms` - Initial delay in milliseconds (default 15 min = 900_000)
|
||||
/// * `max_delay_ms` - Maximum delay in milliseconds (default 24h = 86_400_000)
|
||||
/// * `backoff_factor` - Multiplier per retry (default 2.0)
|
||||
pub fn retry_delay_ms(
|
||||
retry_count: u32,
|
||||
initial_delay_ms: u64,
|
||||
max_delay_ms: u64,
|
||||
backoff_factor: f64,
|
||||
) -> u64 {
|
||||
let delay = (initial_delay_ms as f64) * backoff_factor.powi(retry_count as i32);
|
||||
(delay as u64).min(max_delay_ms)
|
||||
}
|
||||
|
||||
/// Default retry delay with standard parameters.
|
||||
pub fn default_retry_delay_ms(retry_count: u32) -> u64 {
|
||||
retry_delay_ms(
|
||||
retry_count,
|
||||
15 * 60 * 1000, // 15 minutes
|
||||
24 * 60 * 60 * 1000, // 24 hours
|
||||
2.0,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_recipient() {
|
||||
let result = detect_bounce_type(Some("550 5.1.1 User unknown"), None, None);
|
||||
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
|
||||
assert_eq!(result.category, BounceCategory::Hard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_mailbox_full() {
|
||||
let result = detect_bounce_type(Some("552 5.2.2 Mailbox full"), None, None);
|
||||
assert_eq!(result.bounce_type, BounceType::MailboxFull);
|
||||
assert_eq!(result.category, BounceCategory::Hard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_temporary_failure() {
|
||||
let result = detect_bounce_type(Some("451 4.3.0 Try again later"), None, None);
|
||||
assert_eq!(result.bounce_type, BounceType::ServerUnavailable);
|
||||
assert_eq!(result.category, BounceCategory::Soft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_auto_response() {
|
||||
let result = detect_bounce_type(Some("Auto-reply: Out of office"), None, None);
|
||||
assert_eq!(result.bounce_type, BounceType::AutoResponse);
|
||||
assert_eq!(result.category, BounceCategory::AutoResponse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_from_dsn_status() {
|
||||
let result = detect_bounce_type(None, None, Some("5.1.1"));
|
||||
assert_eq!(result.bounce_type, BounceType::InvalidRecipient);
|
||||
|
||||
let result = detect_bounce_type(None, None, Some("4.4.1"));
|
||||
assert_eq!(result.bounce_type, BounceType::NetworkError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_unknown() {
|
||||
let result = detect_bounce_type(Some("Something weird happened"), None, None);
|
||||
assert_eq!(result.bounce_type, BounceType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_bounce_subject() {
|
||||
assert!(is_bounce_subject("Mail Delivery Failure"));
|
||||
assert!(is_bounce_subject("Delivery Status Notification"));
|
||||
assert!(is_bounce_subject("Returned mail: see transcript for details"));
|
||||
assert!(is_bounce_subject("Undeliverable: Your message"));
|
||||
assert!(!is_bounce_subject("Hello World"));
|
||||
assert!(!is_bounce_subject("Meeting tomorrow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_bounce_recipient() {
|
||||
let body = "Delivery to the following recipient failed:\n recipient: user@example.com";
|
||||
assert_eq!(
|
||||
extract_bounce_recipient(body),
|
||||
Some("user@example.com".to_string())
|
||||
);
|
||||
|
||||
let body = "Final-Recipient: rfc822;bounce@test.org";
|
||||
assert_eq!(
|
||||
extract_bounce_recipient(body),
|
||||
Some("bounce@test.org".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_delay() {
|
||||
assert_eq!(default_retry_delay_ms(0), 900_000); // 15 min
|
||||
assert_eq!(default_retry_delay_ms(1), 1_800_000); // 30 min
|
||||
assert_eq!(default_retry_delay_ms(2), 3_600_000); // 1 hour
|
||||
|
||||
// Capped at 24h
|
||||
assert_eq!(default_retry_delay_ms(20), 86_400_000);
|
||||
}
|
||||
}
|
||||
411
rust/crates/mailer-core/src/email.rs
Normal file
411
rust/crates/mailer-core/src/email.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{MailerError, Result};
|
||||
use crate::mime::build_rfc822;
|
||||
use crate::validation::is_valid_email_format;
|
||||
|
||||
/// Email priority level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Priority {
|
||||
High,
|
||||
Normal,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl Default for Priority {
|
||||
fn default() -> Self {
|
||||
Priority::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Priority {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Priority::High => write!(f, "high"),
|
||||
Priority::Normal => write!(f, "normal"),
|
||||
Priority::Low => write!(f, "low"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed email address with local part and domain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct EmailAddress {
|
||||
pub local: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
/// Parse an email address string like "user@example.com" or "Name <user@example.com>".
|
||||
pub fn parse(input: &str) -> Result<Self> {
|
||||
let addr = extract_email_address(input)
|
||||
.ok_or_else(|| MailerError::InvalidEmail(input.to_string()))?;
|
||||
|
||||
let parts: Vec<&str> = addr.splitn(2, '@').collect();
|
||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||
return Err(MailerError::InvalidEmail(input.to_string()));
|
||||
}
|
||||
|
||||
Ok(EmailAddress {
|
||||
local: parts[0].to_string(),
|
||||
domain: parts[1].to_lowercase(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the full address as "local@domain".
|
||||
pub fn address(&self) -> String {
|
||||
format!("{}@{}", self.local, self.domain)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EmailAddress {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}@{}", self.local, self.domain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the bare email address from a string that may contain display names or angle brackets.
|
||||
/// Handles formats like:
|
||||
/// - "user@example.com"
|
||||
/// - "<user@example.com>"
|
||||
/// - "John Doe <user@example.com>"
|
||||
pub fn extract_email_address(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
// Handle null sender
|
||||
if trimmed == "<>" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try to extract from angle brackets
|
||||
if let Some(start) = trimmed.find('<') {
|
||||
if let Some(end) = trimmed.find('>') {
|
||||
if end > start {
|
||||
let addr = trimmed[start + 1..end].trim();
|
||||
if !addr.is_empty() {
|
||||
return Some(addr.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No angle brackets — treat entire string as address if it contains @
|
||||
if trimmed.contains('@') {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// An email attachment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub filename: String,
|
||||
#[serde(with = "serde_bytes_base64")]
|
||||
pub content: Vec<u8>,
|
||||
pub content_type: String,
|
||||
pub content_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Serde helper for base64-encoding Vec<u8> in JSON.
|
||||
mod serde_bytes_base64 {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&STANDARD.encode(data))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
STANDARD.decode(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete email message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Email {
|
||||
pub from: String,
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>,
|
||||
pub bcc: Vec<String>,
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
pub html: Option<String>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub priority: Priority,
|
||||
pub might_be_spam: bool,
|
||||
message_id: Option<String>,
|
||||
envelope_from: Option<String>,
|
||||
}
|
||||
|
||||
impl Email {
|
||||
/// Create a new email with the minimum required fields.
|
||||
pub fn new(from: &str, subject: &str, text: &str) -> Self {
|
||||
Email {
|
||||
from: from.to_string(),
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
bcc: Vec::new(),
|
||||
subject: subject.to_string(),
|
||||
text: text.to_string(),
|
||||
html: None,
|
||||
attachments: Vec::new(),
|
||||
headers: HashMap::new(),
|
||||
priority: Priority::Normal,
|
||||
might_be_spam: false,
|
||||
message_id: None,
|
||||
envelope_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a To recipient.
|
||||
pub fn add_to(&mut self, email: &str) -> &mut Self {
|
||||
self.to.push(email.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a CC recipient.
|
||||
pub fn add_cc(&mut self, email: &str) -> &mut Self {
|
||||
self.cc.push(email.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a BCC recipient.
|
||||
pub fn add_bcc(&mut self, email: &str) -> &mut Self {
|
||||
self.bcc.push(email.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the HTML body.
|
||||
pub fn set_html(&mut self, html: &str) -> &mut Self {
|
||||
self.html = Some(html.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an attachment.
|
||||
pub fn add_attachment(&mut self, attachment: Attachment) -> &mut Self {
|
||||
self.attachments.push(attachment);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a custom header.
|
||||
pub fn add_header(&mut self, name: &str, value: &str) -> &mut Self {
|
||||
self.headers.insert(name.to_string(), value.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set email priority.
|
||||
pub fn set_priority(&mut self, priority: Priority) -> &mut Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the sender domain.
|
||||
pub fn from_domain(&self) -> Option<String> {
|
||||
EmailAddress::parse(&self.from)
|
||||
.ok()
|
||||
.map(|addr| addr.domain)
|
||||
}
|
||||
|
||||
/// Get the sender address (bare email, no display name).
|
||||
pub fn from_address(&self) -> Option<String> {
|
||||
extract_email_address(&self.from)
|
||||
}
|
||||
|
||||
/// Get all recipients (to + cc + bcc), deduplicated.
|
||||
pub fn all_recipients(&self) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
for addr in self.to.iter().chain(self.cc.iter()).chain(self.bcc.iter()) {
|
||||
let lower = addr.to_lowercase();
|
||||
if seen.insert(lower) {
|
||||
result.push(addr.clone());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the primary (first To) recipient.
|
||||
pub fn primary_recipient(&self) -> Option<&str> {
|
||||
self.to.first().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Check whether this email has attachments.
|
||||
pub fn has_attachments(&self) -> bool {
|
||||
!self.attachments.is_empty()
|
||||
}
|
||||
|
||||
/// Get total attachment size in bytes.
|
||||
pub fn attachments_size(&self) -> usize {
|
||||
self.attachments.iter().map(|a| a.content.len()).sum()
|
||||
}
|
||||
|
||||
/// Get or generate a Message-ID.
|
||||
pub fn message_id(&self) -> String {
|
||||
if let Some(ref id) = self.message_id {
|
||||
return id.clone();
|
||||
}
|
||||
let domain = self.from_domain().unwrap_or_else(|| "localhost".to_string());
|
||||
let unique = uuid::Uuid::new_v4();
|
||||
format!("<{}.{}@{}>", chrono_millis(), unique, domain)
|
||||
}
|
||||
|
||||
/// Set an explicit Message-ID.
|
||||
pub fn set_message_id(&mut self, id: &str) -> &mut Self {
|
||||
self.message_id = Some(id.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the envelope-from (MAIL FROM), falls back to the From header address.
|
||||
pub fn envelope_from(&self) -> Option<String> {
|
||||
self.envelope_from
|
||||
.clone()
|
||||
.or_else(|| self.from_address())
|
||||
}
|
||||
|
||||
/// Set the envelope-from address.
|
||||
pub fn set_envelope_from(&mut self, addr: &str) -> &mut Self {
|
||||
self.envelope_from = Some(addr.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sanitize a string by removing CR/LF (header injection prevention).
|
||||
pub fn sanitize_string(input: &str) -> String {
|
||||
input.replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
/// Validate all addresses in this email.
|
||||
pub fn validate_addresses(&self) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if !is_valid_email_format(&self.from) {
|
||||
if extract_email_address(&self.from)
|
||||
.map(|a| !is_valid_email_format(&a))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
errors.push(format!("Invalid from address: {}", self.from));
|
||||
}
|
||||
}
|
||||
|
||||
for addr in &self.to {
|
||||
if !is_valid_email_format(addr) {
|
||||
if extract_email_address(addr)
|
||||
.map(|a| !is_valid_email_format(&a))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
errors.push(format!("Invalid to address: {}", addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for addr in &self.cc {
|
||||
if !is_valid_email_format(addr) {
|
||||
if extract_email_address(addr)
|
||||
.map(|a| !is_valid_email_format(&a))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
errors.push(format!("Invalid cc address: {}", addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for addr in &self.bcc {
|
||||
if !is_valid_email_format(addr) {
|
||||
if extract_email_address(addr)
|
||||
.map(|a| !is_valid_email_format(&a))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
errors.push(format!("Invalid bcc address: {}", addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Convert the email to RFC 5322 format.
|
||||
pub fn to_rfc822(&self) -> Result<String> {
|
||||
build_rfc822(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple epoch millis using std::time (no chrono dependency needed).
|
||||
fn chrono_millis() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_email_address_parse() {
|
||||
let addr = EmailAddress::parse("user@example.com").unwrap();
|
||||
assert_eq!(addr.local, "user");
|
||||
assert_eq!(addr.domain, "example.com");
|
||||
|
||||
let addr = EmailAddress::parse("John Doe <john@example.com>").unwrap();
|
||||
assert_eq!(addr.local, "john");
|
||||
assert_eq!(addr.domain, "example.com");
|
||||
|
||||
let addr = EmailAddress::parse("<admin@test.org>").unwrap();
|
||||
assert_eq!(addr.local, "admin");
|
||||
assert_eq!(addr.domain, "test.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_email_address() {
|
||||
assert_eq!(
|
||||
extract_email_address("John <john@example.com>"),
|
||||
Some("john@example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_email_address("user@example.com"),
|
||||
Some("user@example.com".to_string())
|
||||
);
|
||||
assert_eq!(extract_email_address("<>"), None);
|
||||
assert_eq!(extract_email_address("no-at-sign"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_new() {
|
||||
let mut email = Email::new("sender@example.com", "Test", "Hello");
|
||||
email.add_to("recipient@example.com");
|
||||
assert_eq!(email.from_domain(), Some("example.com".to_string()));
|
||||
assert_eq!(email.all_recipients().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_recipients_dedup() {
|
||||
let mut email = Email::new("sender@example.com", "Test", "Hello");
|
||||
email.add_to("a@example.com");
|
||||
email.add_cc("a@example.com"); // duplicate (case-insensitive)
|
||||
email.add_bcc("b@example.com");
|
||||
assert_eq!(email.all_recipients().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_string() {
|
||||
assert_eq!(Email::sanitize_string("hello\r\nworld"), "hello world");
|
||||
assert_eq!(Email::sanitize_string("normal"), "normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_id_generation() {
|
||||
let email = Email::new("sender@example.com", "Test", "Hello");
|
||||
let mid = email.message_id();
|
||||
assert!(mid.starts_with('<'));
|
||||
assert!(mid.ends_with('>'));
|
||||
assert!(mid.contains("@example.com"));
|
||||
}
|
||||
}
|
||||
31
rust/crates/mailer-core/src/error.rs
Normal file
31
rust/crates/mailer-core/src/error.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Core error types for the mailer system.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MailerError {
|
||||
#[error("invalid email address: {0}")]
|
||||
InvalidEmail(String),
|
||||
|
||||
#[error("invalid email format: {0}")]
|
||||
InvalidFormat(String),
|
||||
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(String),
|
||||
|
||||
#[error("MIME encoding error: {0}")]
|
||||
MimeError(String),
|
||||
|
||||
#[error("validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("parse error: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("regex error: {0}")]
|
||||
Regex(#[from] regex::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, MailerError>;
|
||||
@@ -1,9 +1,25 @@
|
||||
//! mailer-core: Email model, validation, and RFC 5322 primitives.
|
||||
|
||||
pub mod bounce;
|
||||
pub mod email;
|
||||
pub mod error;
|
||||
pub mod mime;
|
||||
pub mod validation;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use bounce::{
|
||||
detect_bounce_type, extract_bounce_recipient, is_bounce_subject, BounceCategory,
|
||||
BounceDetection, BounceType,
|
||||
};
|
||||
pub use email::{extract_email_address, Attachment, Email, EmailAddress, Priority};
|
||||
pub use error::{MailerError, Result};
|
||||
pub use mime::build_rfc822;
|
||||
pub use validation::{is_valid_email_format, validate_email, EmailValidationResult};
|
||||
|
||||
/// Re-export mailparse for MIME parsing.
|
||||
pub use mailparse;
|
||||
|
||||
/// Placeholder for email address validation and data types.
|
||||
/// Crate version.
|
||||
pub fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
377
rust/crates/mailer-core/src/mime.rs
Normal file
377
rust/crates/mailer-core/src/mime.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
|
||||
use crate::email::Email;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Generate a MIME boundary string.
|
||||
fn generate_boundary() -> String {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
format!("----=_Part_{}", id.as_simple())
|
||||
}
|
||||
|
||||
/// Build an RFC 5322 compliant email message from an Email struct.
|
||||
pub fn build_rfc822(email: &Email) -> Result<String> {
|
||||
let mut output = String::with_capacity(4096);
|
||||
let message_id = email.message_id();
|
||||
|
||||
// Required headers
|
||||
output.push_str(&format!(
|
||||
"From: {}\r\n",
|
||||
Email::sanitize_string(&email.from)
|
||||
));
|
||||
|
||||
if !email.to.is_empty() {
|
||||
output.push_str(&format!(
|
||||
"To: {}\r\n",
|
||||
email
|
||||
.to
|
||||
.iter()
|
||||
.map(|a| Email::sanitize_string(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !email.cc.is_empty() {
|
||||
output.push_str(&format!(
|
||||
"Cc: {}\r\n",
|
||||
email
|
||||
.cc
|
||||
.iter()
|
||||
.map(|a| Email::sanitize_string(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
output.push_str(&format!(
|
||||
"Subject: {}\r\n",
|
||||
Email::sanitize_string(&email.subject)
|
||||
));
|
||||
output.push_str(&format!("Message-ID: {}\r\n", message_id));
|
||||
output.push_str(&format!("Date: {}\r\n", rfc2822_now()));
|
||||
output.push_str("MIME-Version: 1.0\r\n");
|
||||
|
||||
// Priority headers
|
||||
match email.priority {
|
||||
crate::email::Priority::High => {
|
||||
output.push_str("X-Priority: 1\r\n");
|
||||
output.push_str("Importance: high\r\n");
|
||||
}
|
||||
crate::email::Priority::Low => {
|
||||
output.push_str("X-Priority: 5\r\n");
|
||||
output.push_str("Importance: low\r\n");
|
||||
}
|
||||
crate::email::Priority::Normal => {}
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
for (name, value) in &email.headers {
|
||||
output.push_str(&format!(
|
||||
"{}: {}\r\n",
|
||||
Email::sanitize_string(name),
|
||||
Email::sanitize_string(value)
|
||||
));
|
||||
}
|
||||
|
||||
let has_html = email.html.is_some();
|
||||
let has_attachments = !email.attachments.is_empty();
|
||||
|
||||
match (has_html, has_attachments) {
|
||||
(false, false) => {
|
||||
// Plain text only
|
||||
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||
output.push_str("\r\n");
|
||||
output.push_str("ed_printable_encode(&email.text));
|
||||
}
|
||||
(true, false) => {
|
||||
// multipart/alternative (text + html)
|
||||
let boundary = generate_boundary();
|
||||
output.push_str(&format!(
|
||||
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
|
||||
boundary
|
||||
));
|
||||
output.push_str("\r\n");
|
||||
|
||||
// Text part
|
||||
output.push_str(&format!("--{}\r\n", boundary));
|
||||
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||
output.push_str("\r\n");
|
||||
output.push_str("ed_printable_encode(&email.text));
|
||||
output.push_str("\r\n");
|
||||
|
||||
// HTML part
|
||||
output.push_str(&format!("--{}\r\n", boundary));
|
||||
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
|
||||
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||
output.push_str("\r\n");
|
||||
output.push_str("ed_printable_encode(email.html.as_deref().unwrap()));
|
||||
output.push_str("\r\n");
|
||||
|
||||
output.push_str(&format!("--{}--\r\n", boundary));
|
||||
}
|
||||
(_, true) => {
|
||||
// multipart/mixed with optional multipart/alternative inside
|
||||
let mixed_boundary = generate_boundary();
|
||||
output.push_str(&format!(
|
||||
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
|
||||
mixed_boundary
|
||||
));
|
||||
output.push_str("\r\n");
|
||||
|
||||
if has_html {
|
||||
// multipart/alternative for text+html
|
||||
let alt_boundary = generate_boundary();
|
||||
output.push_str(&format!("--{}\r\n", mixed_boundary));
|
||||
output.push_str(&format!(
|
||||
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n",
|
||||
alt_boundary
|
||||
));
|
||||
output.push_str("\r\n");
|
||||
|
||||
// Text part
|
||||
output.push_str(&format!("--{}\r\n", alt_boundary));
|
||||
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||
output.push_str("\r\n");
|
||||
output.push_str("ed_printable_encode(&email.text));
|
||||
output.push_str("\r\n");
|
||||
|
||||
// HTML part
|
||||
output.push_str(&format!("--{}\r\n", alt_boundary));
|
||||
output.push_str("Content-Type: text/html; charset=UTF-8\r\n");
|
||||
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||
output.push_str("\r\n");
|
||||
output.push_str("ed_printable_encode(email.html.as_deref().unwrap()));
|
||||
output.push_str("\r\n");
|
||||
|
||||
output.push_str(&format!("--{}--\r\n", alt_boundary));
|
||||
} else {
|
||||
// Plain text only
|
||||
output.push_str(&format!("--{}\r\n", mixed_boundary));
|
||||
output.push_str("Content-Type: text/plain; charset=UTF-8\r\n");
|
||||
output.push_str("Content-Transfer-Encoding: quoted-printable\r\n");
|
||||
output.push_str("\r\n");
|
||||
output.push_str("ed_printable_encode(&email.text));
|
||||
output.push_str("\r\n");
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for attachment in &email.attachments {
|
||||
output.push_str(&format!("--{}\r\n", mixed_boundary));
|
||||
output.push_str(&format!(
|
||||
"Content-Type: {}; name=\"{}\"\r\n",
|
||||
attachment.content_type, attachment.filename
|
||||
));
|
||||
output.push_str("Content-Transfer-Encoding: base64\r\n");
|
||||
|
||||
if let Some(ref cid) = attachment.content_id {
|
||||
output.push_str(&format!("Content-ID: <{}>\r\n", cid));
|
||||
output.push_str("Content-Disposition: inline\r\n");
|
||||
} else {
|
||||
output.push_str(&format!(
|
||||
"Content-Disposition: attachment; filename=\"{}\"\r\n",
|
||||
attachment.filename
|
||||
));
|
||||
}
|
||||
|
||||
output.push_str("\r\n");
|
||||
output.push_str(&base64_encode_wrapped(&attachment.content));
|
||||
output.push_str("\r\n");
|
||||
}
|
||||
|
||||
output.push_str(&format!("--{}--\r\n", mixed_boundary));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Encode a string as quoted-printable (RFC 2045).
|
||||
fn quoted_printable_encode(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len() * 2);
|
||||
let mut line_len = 0;
|
||||
|
||||
for byte in input.bytes() {
|
||||
let encoded = match byte {
|
||||
// Printable ASCII that doesn't need encoding (except =)
|
||||
b' '..=b'<' | b'>'..=b'~' => {
|
||||
line_len += 1;
|
||||
(byte as char).to_string()
|
||||
}
|
||||
b'\t' => {
|
||||
line_len += 1;
|
||||
"\t".to_string()
|
||||
}
|
||||
b'\r' => continue, // handled with \n
|
||||
b'\n' => {
|
||||
line_len = 0;
|
||||
"\r\n".to_string()
|
||||
}
|
||||
_ => {
|
||||
line_len += 3;
|
||||
format!("={:02X}", byte)
|
||||
}
|
||||
};
|
||||
|
||||
// Soft line break at 76 characters
|
||||
if line_len > 75 && byte != b'\n' {
|
||||
output.push_str("=\r\n");
|
||||
line_len = encoded.len();
|
||||
}
|
||||
|
||||
output.push_str(&encoded);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Base64-encode binary data with 76-character line wrapping.
|
||||
fn base64_encode_wrapped(data: &[u8]) -> String {
|
||||
let encoded = STANDARD.encode(data);
|
||||
let mut output = String::with_capacity(encoded.len() + encoded.len() / 76 * 2);
|
||||
for (i, ch) in encoded.chars().enumerate() {
|
||||
if i > 0 && i % 76 == 0 {
|
||||
output.push_str("\r\n");
|
||||
}
|
||||
output.push(ch);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Generate current date in RFC 2822 format (e.g., "Tue, 10 Feb 2026 12:00:00 +0000").
|
||||
fn rfc2822_now() -> String {
|
||||
use std::time::SystemTime;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Simple UTC formatting without chrono dependency
|
||||
let days = now / 86400;
|
||||
let time_of_day = now % 86400;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
let seconds = time_of_day % 60;
|
||||
|
||||
// Calculate year/month/day from days since epoch
|
||||
let (year, month, day) = days_to_ymd(days);
|
||||
|
||||
let day_of_week = ((days + 4) % 7) as usize; // Jan 1 1970 = Thursday (4)
|
||||
let dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
let mon = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
format!(
|
||||
"{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000",
|
||||
dow[day_of_week],
|
||||
day,
|
||||
mon[(month - 1) as usize],
|
||||
year,
|
||||
hours,
|
||||
minutes,
|
||||
seconds
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert days since Unix epoch to (year, month, day).
|
||||
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
|
||||
// Algorithm from https://howardhinnant.github.io/date_algorithms.html
|
||||
let z = days + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::email::Email;
|
||||
|
||||
#[test]
|
||||
fn test_plain_text_email() {
|
||||
let mut email = Email::new("sender@example.com", "Test Subject", "Hello World");
|
||||
email.add_to("recipient@example.com");
|
||||
email.set_message_id("<test@example.com>");
|
||||
|
||||
let rfc822 = build_rfc822(&email).unwrap();
|
||||
assert!(rfc822.contains("From: sender@example.com"));
|
||||
assert!(rfc822.contains("To: recipient@example.com"));
|
||||
assert!(rfc822.contains("Subject: Test Subject"));
|
||||
assert!(rfc822.contains("Content-Type: text/plain; charset=UTF-8"));
|
||||
assert!(rfc822.contains("Hello World"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_email() {
|
||||
let mut email = Email::new("sender@example.com", "HTML Test", "Plain text");
|
||||
email.add_to("recipient@example.com");
|
||||
email.set_html("<p>HTML content</p>");
|
||||
email.set_message_id("<test@example.com>");
|
||||
|
||||
let rfc822 = build_rfc822(&email).unwrap();
|
||||
assert!(rfc822.contains("multipart/alternative"));
|
||||
assert!(rfc822.contains("text/plain"));
|
||||
assert!(rfc822.contains("text/html"));
|
||||
assert!(rfc822.contains("Plain text"));
|
||||
assert!(rfc822.contains("HTML content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_with_attachment() {
|
||||
let mut email = Email::new("sender@example.com", "Attachment Test", "See attached");
|
||||
email.add_to("recipient@example.com");
|
||||
email.set_message_id("<test@example.com>");
|
||||
email.add_attachment(crate::email::Attachment {
|
||||
filename: "test.txt".to_string(),
|
||||
content: b"Hello attachment".to_vec(),
|
||||
content_type: "text/plain".to_string(),
|
||||
content_id: None,
|
||||
});
|
||||
|
||||
let rfc822 = build_rfc822(&email).unwrap();
|
||||
assert!(rfc822.contains("multipart/mixed"));
|
||||
assert!(rfc822.contains("Content-Disposition: attachment"));
|
||||
assert!(rfc822.contains("test.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quoted_printable() {
|
||||
let input = "Hello = World";
|
||||
let encoded = quoted_printable_encode(input);
|
||||
assert!(encoded.contains("=3D")); // = is encoded
|
||||
|
||||
let input = "Plain ASCII text";
|
||||
let encoded = quoted_printable_encode(input);
|
||||
assert_eq!(encoded, "Plain ASCII text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_wrapped() {
|
||||
let data = vec![0u8; 100];
|
||||
let encoded = base64_encode_wrapped(&data);
|
||||
for line in encoded.split("\r\n") {
|
||||
assert!(line.len() <= 76);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rfc2822_date() {
|
||||
let date = rfc2822_now();
|
||||
// Should match pattern like "Tue, 10 Feb 2026 12:00:00 +0000"
|
||||
assert!(date.contains("+0000"));
|
||||
assert!(date.len() > 20);
|
||||
}
|
||||
}
|
||||
178
rust/crates/mailer-core/src/validation.rs
Normal file
178
rust/crates/mailer-core/src/validation.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Basic email format regex — covers the vast majority of valid email addresses.
|
||||
/// Does NOT attempt to match the full RFC 5321 grammar (which is impractical via regex).
|
||||
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$",
|
||||
)
|
||||
.expect("invalid email regex")
|
||||
});
|
||||
|
||||
/// Check whether an email address has valid syntax.
|
||||
pub fn is_valid_email_format(email: &str) -> bool {
|
||||
let email = email.trim();
|
||||
if email.is_empty() || email.len() > 254 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let local = parts[1];
|
||||
let domain = parts[0];
|
||||
|
||||
// Local part max 64 chars
|
||||
if local.is_empty() || local.len() > 64 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Domain must have at least one dot (TLD only not valid for email)
|
||||
if !domain.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
EMAIL_REGEX.is_match(email)
|
||||
}
|
||||
|
||||
/// Email validation result with scoring.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmailValidationResult {
|
||||
pub is_valid: bool,
|
||||
pub format_valid: bool,
|
||||
pub score: f64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Validate an email address (synchronous, format-only).
|
||||
/// DNS-based validation (MX records, disposable domains) would require async and is
|
||||
/// intended for the N-API bridge layer where the TypeScript side already has DNS access.
|
||||
pub fn validate_email(email: &str) -> EmailValidationResult {
|
||||
let format_valid = is_valid_email_format(email);
|
||||
|
||||
if !format_valid {
|
||||
return EmailValidationResult {
|
||||
is_valid: false,
|
||||
format_valid: false,
|
||||
score: 0.0,
|
||||
error_message: Some(format!("Invalid email format: {}", email)),
|
||||
};
|
||||
}
|
||||
|
||||
// Role account detection (weight 0.1 penalty)
|
||||
let local = email.split('@').next().unwrap_or("");
|
||||
let is_role = is_role_account(local);
|
||||
|
||||
// Score: format (0.4) + assumed-mx (0.3) + assumed-not-disposable (0.2) + role (0.1)
|
||||
let mut score = 0.4 + 0.3 + 0.2; // format + mx + not-disposable
|
||||
if !is_role {
|
||||
score += 0.1;
|
||||
}
|
||||
|
||||
EmailValidationResult {
|
||||
is_valid: score >= 0.7,
|
||||
format_valid: true,
|
||||
score,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a local part is a common role account.
|
||||
fn is_role_account(local: &str) -> bool {
|
||||
const ROLE_ACCOUNTS: &[&str] = &[
|
||||
"abuse",
|
||||
"admin",
|
||||
"administrator",
|
||||
"billing",
|
||||
"compliance",
|
||||
"devnull",
|
||||
"dns",
|
||||
"ftp",
|
||||
"hostmaster",
|
||||
"info",
|
||||
"inoc",
|
||||
"ispfeedback",
|
||||
"ispsupport",
|
||||
"list",
|
||||
"list-request",
|
||||
"maildaemon",
|
||||
"mailer-daemon",
|
||||
"mailerdaemon",
|
||||
"marketing",
|
||||
"noc",
|
||||
"no-reply",
|
||||
"noreply",
|
||||
"null",
|
||||
"phish",
|
||||
"phishing",
|
||||
"postmaster",
|
||||
"privacy",
|
||||
"registrar",
|
||||
"root",
|
||||
"sales",
|
||||
"security",
|
||||
"spam",
|
||||
"support",
|
||||
"sysadmin",
|
||||
"tech",
|
||||
"undisclosed-recipients",
|
||||
"unsubscribe",
|
||||
"usenet",
|
||||
"uucp",
|
||||
"webmaster",
|
||||
"www",
|
||||
];
|
||||
let lower = local.to_lowercase();
|
||||
ROLE_ACCOUNTS.contains(&lower.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_emails() {
|
||||
assert!(is_valid_email_format("user@example.com"));
|
||||
assert!(is_valid_email_format("first.last@example.com"));
|
||||
assert!(is_valid_email_format("user+tag@example.com"));
|
||||
assert!(is_valid_email_format("user@sub.domain.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_emails() {
|
||||
assert!(!is_valid_email_format(""));
|
||||
assert!(!is_valid_email_format("@"));
|
||||
assert!(!is_valid_email_format("user@"));
|
||||
assert!(!is_valid_email_format("@domain.com"));
|
||||
assert!(!is_valid_email_format("user@domain")); // no TLD
|
||||
assert!(!is_valid_email_format("user @domain.com")); // space
|
||||
assert!(!is_valid_email_format("user@.com")); // leading dot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_email_scoring() {
|
||||
let result = validate_email("user@example.com");
|
||||
assert!(result.is_valid);
|
||||
assert!(result.score >= 0.9);
|
||||
|
||||
let result = validate_email("postmaster@example.com");
|
||||
assert!(result.is_valid);
|
||||
assert!(result.score >= 0.7);
|
||||
assert!(result.score < 1.0); // role account penalty
|
||||
|
||||
let result = validate_email("not-an-email");
|
||||
assert!(!result.is_valid);
|
||||
assert_eq!(result.score, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_accounts() {
|
||||
assert!(is_role_account("postmaster"));
|
||||
assert!(is_role_account("abuse"));
|
||||
assert!(is_role_account("noreply"));
|
||||
assert!(!is_role_account("john"));
|
||||
assert!(!is_role_account("alice"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user