feat(rust): implement mailer-core and mailer-security crates with CLI
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s

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:
2026-02-10 16:06:04 +00:00
parent 91b49182bb
commit 199b9b79d2
15 changed files with 3113 additions and 261 deletions

View 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);
}
}

View 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"));
}
}

View 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>;

View File

@@ -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")
}

View 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(&quoted_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(&quoted_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(&quoted_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(&quoted_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(&quoted_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(&quoted_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);
}
}

View 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"));
}
}