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:
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