NonInventPurchasingSystem/CPRNIMS.Infrastructure/Helper/EmailValidationService.cs
2026-03-02 12:25:08 +08:00

177 lines
8.0 KiB
C#

using CPRNIMS.Infrastructure.Dto.SMTP;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace CPRNIMS.Infrastructure.Helper
{
public class EmailValidationService
{
// In-memory cache for MX lookups { domain -> (isValid, timestamp) }
private static readonly Dictionary<string, (bool IsValid, DateTime CachedAt)> _mxCache = new();
private static readonly TimeSpan _mxCacheExpiry = TimeSpan.FromHours(24);
// Persistent bounce list - load from your DB or file
private static readonly HashSet<string> _bounceList = new(StringComparer.OrdinalIgnoreCase);
// Known disposable/fake email domains (expand this list as needed)
private static readonly HashSet<string> _disposableDomains = new(StringComparer.OrdinalIgnoreCase)
{
// Disposable/temp mail providers
"mailinator.com", "guerrillamail.com", "tempmail.com", "throwaway.email",
"yopmail.com", "sharklasers.com", "guerrillamailblock.com", "grr.la",
"guerrillamail.info", "spam4.me", "trashmail.com", "trashmail.me",
"trashmail.net", "trashmail.at", "trashmail.io", "trashmail.org",
"fakeinbox.com", "mailnull.com", "spamgourmet.com", "spamgourmet.net",
"spamgourmet.org", "maildrop.cc", "dispostable.com", "mailexpire.com",
"spamex.com", "deadaddress.com", "spamfree24.org", "no-spam.ws",
"discard.email", "despam.it", "emailsensei.com", "getonemail.com",
"spamfree.eu", "spaml.com", "spammotel.com", "spamspot.com",
"tempe-mail.com", "tempinbox.com", "temp-mail.org", "emailtemporanea.net",
"crazymailing.com", "dispostable.com", "spamgob.com", "0-mail.com",
"0815.ru", "0clickemail.com", "10minutemail.com", "20minutemail.com",
"filzmail.com", "getnada.com", "incognitomail.com", "jetable.fr.nf",
"lortemail.dk", "mytempemail.com", "noclickemail.com", "nowmymail.com",
"objectmail.com", "odaymail.com", "onewaymail.com", "pookmail.com",
"privacy.net", "proxymail.eu", "rcpt.at", "rklips.com",
"shortmail.net", "sogetthis.com", "spamgob.com", "spaml.de",
"speed.1s.fr", "supergreatmail.com", "suremail.info", "tempail.com",
"tempemail.net", "temporarioemail.com.br", "thanksnospam.info",
"thisisnotmyrealemail.com", "throwam.com", "trbvm.com", "truckmail.com",
"tyldd.com", "uggsrock.com", "veryrealemail.com", "vidchart.com",
"wegwerfmail.de", "wegwerfmail.net", "wegwerfmail.org", "wh4f.org",
"whyspam.me", "willselfdestruct.com", "wronghead.com", "wuzupmail.net",
"xagloo.com", "xemaps.com", "xents.com", "xmaily.com", "xoxy.net",
"yep.it", "yogamaven.com", "yuurok.com", "zehnminutenmail.de",
"zippymail.info", "zoaxe.com", "zoemail.net", "zoemail.org"
};
// Known legitimate domains — skip MX lookup to save time
private static readonly HashSet<string> _trustedDomains = new(StringComparer.OrdinalIgnoreCase)
{
"gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "live.com",
"icloud.com", "me.com", "mac.com", "msn.com", "aol.com",
"protonmail.com", "proton.me", "zoho.com", "yandex.com",
"fastmail.com", "hey.com", "tutanota.com"
};
/// <summary>
/// Fast multi-layer email validation without SMTP handshake.
/// Average time per email: ~5-50ms (MX lookup only on unknown domains, cached after first hit).
/// </summary>
public async Task<EmailValidationResult> ValidateAsync(string email)
{
email = email.Trim().ToLowerInvariant();
// LAYER 1: Regex format check (instant)
if (!IsValidFormat(email))
return EmailValidationResult.Fail(email, "Invalid email format");
var domain = email.Split('@')[1];
// LAYER 2: Bounce list check (instant - known bad addresses from past sends)
if (_bounceList.Contains(email))
return EmailValidationResult.Fail(email, "Previously bounced email address");
// LAYER 3: Disposable/fake domain check (instant - local hashset lookup)
if (_disposableDomains.Contains(domain))
return EmailValidationResult.Fail(email, $"Disposable email domain: {domain}");
// LAYER 4: Trusted domain fast-pass (instant - skip DNS for known providers)
if (_trustedDomains.Contains(domain))
return EmailValidationResult.Pass(email);
// LAYER 5: MX Record check with caching (fast after first lookup)
bool hasMx = await HasValidMxRecordAsync(domain);
if (!hasMx)
return EmailValidationResult.Fail(email, $"No MX record found for domain: {domain}");
return EmailValidationResult.Pass(email);
}
/// <summary>
/// Validates a batch of emails concurrently for performance.
/// </summary>
public async Task<List<EmailValidationResult>> ValidateBatchAsync(IEnumerable<string> emails)
{
var tasks = emails.Select(e => ValidateAsync(e));
var results = await Task.WhenAll(tasks);
return results.ToList();
}
/// <summary>
/// Call this after a send attempt — records bounced addresses so they're
/// skipped automatically in future sends.
/// </summary>
public void RecordBounce(string email)
{
_bounceList.Add(email.Trim().ToLowerInvariant());
Console.WriteLine($"[BounceTracker] Recorded bounced email: {email}");
// TODO: Persist to your database here
// await _dbContext.BouncedEmails.AddAsync(new BouncedEmail { Address = email, Date = DateTime.UtcNow });
}
private bool IsValidFormat(string email)
{
var regex = new Regex(
@"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$",
RegexOptions.IgnoreCase
);
return regex.IsMatch(email);
}
private async Task<bool> HasValidMxRecordAsync(string domain)
{
// Return cached result if still fresh
if (_mxCache.TryGetValue(domain, out var cached))
{
if (DateTime.UtcNow - cached.CachedAt < _mxCacheExpiry)
return cached.IsValid;
}
try
{
var processInfo = new System.Diagnostics.ProcessStartInfo("nslookup")
{
Arguments = $"-type=MX {domain}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(processInfo);
if (process == null)
{
_mxCache[domain] = (true, DateTime.UtcNow); // assume valid on error
return true;
}
// Timeout nslookup after 5 seconds
var outputTask = process.StandardOutput.ReadToEndAsync();
if (await Task.WhenAny(outputTask, Task.Delay(5000)) != outputTask)
{
Console.WriteLine($"[MX] DNS lookup timed out for {domain}, assuming valid.");
_mxCache[domain] = (true, DateTime.UtcNow);
return true;
}
string output = await outputTask;
bool hasMx = Regex.IsMatch(output, @"mail exchanger", RegexOptions.IgnoreCase);
_mxCache[domain] = (hasMx, DateTime.UtcNow);
Console.WriteLine($"[MX] {domain} → {(hasMx ? "Valid MX" : "No MX found")} (cached)");
return hasMx;
}
catch (Exception ex)
{
Console.WriteLine($"[MX] Lookup error for {domain}: {ex.Message}");
_mxCache[domain] = (true, DateTime.UtcNow); // assume valid on error
return true;
}
}
}
}