Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/utils/email-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,48 @@ export interface EmailValidation {
// Reasonable syntax check (not full RFC 5322 — deliberately conservative).
const SYNTAX_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;

// TLDs we never mail (RU/SU — not a target audience, hurts sending reputation).
const BLOCKED_TLDS = [".ru", ".su"];

// Exact registrable domains we block: Russian webmail hosted on other TLDs +
// common disposable/throwaway providers. Matches the domain or any subdomain.
const BLOCKED_DOMAINS = new Set<string>([
// Russian webmail (mail.ru/list.ru/bk.ru/inbox.ru are already .ru)
"yandex.com",
"yandex.by",
"yandex.kz",
"ya.ru",
// Disposable / throwaway (curated subset of the most common)
"mailinator.com",
"guerrillamail.com",
"guerrillamailblock.com",
"sharklasers.com",
"10minutemail.com",
"temp-mail.org",
"tempmail.com",
"throwawaymail.com",
"yopmail.com",
"getnada.com",
"dispostable.com",
"maildrop.cc",
"fakeinbox.com",
"trashmail.com",
"mailnesia.com",
"mohmal.com",
"emailondeck.com",
"spam4.me",
]);

function isBlockedDomain(domain: string): boolean {
if (BLOCKED_TLDS.some((tld) => domain.endsWith(tld))) return true;
if (BLOCKED_DOMAINS.has(domain)) return true;
// Subdomain of a blocked domain (e.g. foo.mailinator.com)
for (const d of BLOCKED_DOMAINS) {
if (domain.endsWith("." + d)) return true;
}
return false;
}

// Per-domain MX cache — registry data repeats domains heavily, so one lookup
// per domain saves tens of thousands of DNS queries on a 60k import.
const mxCache = new Map<string, boolean>();
Expand All @@ -30,12 +72,16 @@ async function domainHasMx(domain: string): Promise<boolean> {
return ok;
}

// Full check: syntax + the domain actually has mail exchangers.
// This is what cuts the bounce rate on stale registry addresses.
export { isBlockedDomain };

// Full check: syntax → domain blocklist (RU/SU + disposable) → MX records.
// This is what cuts the bounce rate on stale registry addresses and keeps us
// off domains we should never mail.
export async function validateEmail(email: string): Promise<EmailValidation> {
const addr = email.trim().toLowerCase();
if (!validateSyntax(addr)) return { valid: false, reason: "syntax" };
const domain = addr.slice(addr.lastIndexOf("@") + 1);
if (isBlockedDomain(domain)) return { valid: false, reason: "blocked" };
if (!(await domainHasMx(domain))) return { valid: false, reason: "no_mx" };
return { valid: true };
}
22 changes: 21 additions & 1 deletion tests/email-validate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { validateSyntax } from "../src/utils/email-validate.js";
import { validateSyntax, isBlockedDomain } from "../src/utils/email-validate.js";

describe("email syntax validation", () => {
it("accepts normal addresses", () => {
Expand All @@ -13,3 +13,23 @@ describe("email syntax validation", () => {
}
});
});

describe("domain blocklist", () => {
it("blocks RU/SU TLDs", () => {
for (const d of ["mail.ru", "list.ru", "bk.ru", "example.su", "foo.mail.ru"]) {
expect(isBlockedDomain(d)).toBe(true);
}
});

it("blocks RU webmail on other TLDs and disposable providers", () => {
for (const d of ["yandex.com", "ya.ru", "mailinator.com", "yopmail.com", "sub.mailinator.com"]) {
expect(isBlockedDomain(d)).toBe(true);
}
});

it("allows normal domains", () => {
for (const d of ["gmail.com", "ukr.net", "i.ua", "meta.ua", "law.com.ua"]) {
expect(isBlockedDomain(d)).toBe(false);
}
});
});
Loading