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.
179 lines
4.9 KiB
Rust
179 lines
4.9 KiB
Rust
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"));
|
|
}
|
|
}
|