1
2
3const host = (h) => String(h || '').toLowerCase().replace(/\.$/, '');
4const endsWithAny = (h, suffixes) => suffixes.some((s) => h === s || h.endsWith(`.${s}`));
5
6
7export const MAILBOX_PROVIDERS = [
8 { id: 'google_workspace', name: 'Google Workspace', espMatch: 'google', mx: ['google.com', 'googlemail.com', 'smtp.google.com'] },
9 { id: 'microsoft_365', name: 'Microsoft 365', espMatch: 'microsoft', mx: ['protection.outlook.com', 'mail.protection.outlook.com', 'outlook.com', 'hotmail.com', 'outlook.cn', 'protection.outlook.de', 'mx.microsoft'] },
10 { id: 'zoho', name: 'Zoho Mail', espMatch: 'other', mx: ['zoho.com', 'zoho.eu', 'zoho.in', 'zoho.com.au', 'zoho.jp', 'zohomail.com'] },
11 { id: 'yahoo', name: 'Yahoo Mail', espMatch: 'other', mx: ['yahoodns.net', 'yahoo.com'] },
12 { id: 'icloud', name: 'Apple iCloud Mail', espMatch: 'other', mx: ['icloud.com', 'me.com'] },
13 { id: 'proton', name: 'Proton Mail', espMatch: 'other', mx: ['protonmail.ch', 'proton.me'] },
14 { id: 'fastmail', name: 'Fastmail', espMatch: 'other', mx: ['messagingengine.com', 'fastmail.com'] },
15 { id: 'amazon_workmail', name: 'Amazon WorkMail / SES inbound', espMatch: 'other', mx: ['awsapps.com', 'amazonaws.com'] },
16 { id: 'rackspace', name: 'Rackspace Email', espMatch: 'other', mx: ['emailsrvr.com'] },
17 { id: 'godaddy', name: 'GoDaddy Email', espMatch: 'other', mx: ['secureserver.net'] },
18 { id: 'ionos', name: 'IONOS (1&1)', espMatch: 'other', mx: ['ionos.com', 'ionos.de', 'ionos.co.uk', 'ionos.es', 'ionos.fr', 'perfora.net', 'kundenserver.de', '1and1.com'] },
19 { id: 'ovh', name: 'OVHcloud', espMatch: 'other', mx: ['ovh.net', 'ovh.com'] },
20 { id: 'strato', name: 'Strato', espMatch: 'other', mx: ['rzone.de', 'strato.de'] },
21 { id: 'gmx_webde', name: 'GMX / WEB.DE', espMatch: 'other', mx: ['gmx.net', 'gmx.com', 'web.de'] },
22 { id: 'one_com', name: 'one.com', espMatch: 'other', mx: ['one.com'] },
23 { id: 'namecheap', name: 'Namecheap Private Email', espMatch: 'other', mx: ['privateemail.com', 'registrar-servers.com'] },
24 { id: 'hostinger', name: 'Hostinger Email', espMatch: 'other', mx: ['hostinger.com', 'hostinger.io'] },
25 { id: 'titan', name: 'Titan Email', espMatch: 'other', mx: ['titan.email'] },
26 { id: 'yandex', name: 'Yandex 360', espMatch: 'other', mx: ['yandex.net', 'yandex.ru'] },
27 { id: 'mail_ru', name: 'VK WorkSpace / Mail.ru', espMatch: 'other', mx: ['mail.ru'] },
28 { id: 'tencent', name: 'Tencent Exmail / QQ', espMatch: 'other', mx: ['qq.com', 'exmail.qq.com'] },
29 { id: 'netease', name: 'NetEase Enterprise Mail', espMatch: 'other', mx: ['163.com', 'qiye.163.com', '126.com'] },
30 { id: 'alibaba', name: 'Alibaba Mail', espMatch: 'other', mx: ['mxhichina.com', 'aliyun.com', 'qiye.aliyun.com'] },
31 { id: 'naver', name: 'Naver Works', espMatch: 'other', mx: ['naver.com', 'worksmobile.com'] },
32 { id: 'cloudflare_routing', name: 'Cloudflare Email Routing (forwarding)', espMatch: 'other', type: 'forwarding', mx: ['mx.cloudflare.net'] },
33 { id: 'improvmx', name: 'ImprovMX (forwarding)', espMatch: 'other', type: 'forwarding', mx: ['improvmx.com'] },
34 { id: 'forwardemail', name: 'Forward Email (forwarding)', espMatch: 'other', type: 'forwarding', mx: ['forwardemail.net'] },
35 { id: 'mailgun_inbound', name: 'Mailgun inbound routing', espMatch: 'other', type: 'forwarding', mx: ['mailgun.org'] },
36];
37
38
39export const GATEWAYS = [
40 { id: 'proofpoint', name: 'Proofpoint', mx: ['pphosted.com', 'ppe-hosted.com', 'proofpoint.com', 'ppops.net'] },
41 { id: 'mimecast', name: 'Mimecast', mx: ['mimecast.com', 'mimecast.co.za', 'mimecast-offshore.com'] },
42 { id: 'barracuda', name: 'Barracuda', mx: ['barracudanetworks.com', 'barracuda.com', 'cudamail.com'] },
43 { id: 'cisco', name: 'Cisco Secure Email (IronPort)', mx: ['iphmx.com'] },
44 { id: 'trend_micro', name: 'Trend Micro Email Security', mx: ['tmes.trendmicro.com', 'tmes.trendmicro.eu', 'trendmicro.com', 'trendmicro.eu'] },
45 { id: 'sophos', name: 'Sophos Email', mx: ['sophos.com'] },
46 { id: 'forcepoint', name: 'Forcepoint Email Security', mx: ['mailcontrol.com'] },
47 { id: 'symantec', name: 'Broadcom Symantec Email Security.cloud', mx: ['messagelabs.com'] },
48 { id: 'trellix', name: 'Trellix (FireEye) Email Security', mx: ['fireeyecloud.com', 'trellix.com'] },
49 { id: 'hornetsecurity', name: 'Hornetsecurity', mx: ['hornetsecurity.com', 'hornetsecurity.net'] },
50 { id: 'spamtitan', name: 'SpamTitan', mx: ['spamtitan.com'] },
51 { id: 'mailchannels', name: 'MailChannels', mx: ['mailchannels.net'] },
52 { id: 'n_able_spamexperts', name: 'N-able Mail Assure / SpamExperts', mx: ['spamexperts.com', 'spamexperts.eu', 'spamexperts.net', 'antispamcloud.com'] },
53 { id: 'siteground', name: 'SiteGround anti-spam', mx: ['mailspamprotection.com'] },
54 { id: 'vade', name: 'Vade Secure', mx: ['vadesecure.com'] },
55 { id: 'zix', name: 'Zix / OpenText', mx: ['zixmail.net'] },
56 { id: 'cloudflare_email_security', name: 'Cloudflare Email Security (Area 1)', mx: ['cf-emailsecurity.net', 'area1security.com'] },
57 { id: 'abnormal', name: 'Abnormal Security', mx: ['abnormalsecurity.com'] },
58 { id: 'checkpoint', name: 'Check Point Harmony Email', mx: ['checkpointcloudsec.com'] },
59 { id: 'libraesva', name: 'Libraesva', mx: ['libraesva.com', 'libraesva.cloud'] },
60 { id: 'heinlein', name: 'Heinlein / mailbox.org', mx: ['mailbox.org'] },
61];
62
63
64export const SPF_SERVICES = [
65 ['_spf.google.com', 'Google Workspace'], ['spf.protection.outlook.com', 'Microsoft 365'], ['zoho.com', 'Zoho'], ['zoho.eu', 'Zoho'], ['zohomail.com', 'Zoho'],
66 ['sendgrid.net', 'SendGrid'], ['mailgun.org', 'Mailgun'], ['amazonses.com', 'Amazon SES'], ['mcsv.net', 'Mailchimp'], ['mandrillapp.com', 'Mailchimp Transactional (Mandrill)'],
67 ['_spf.salesforce.com', 'Salesforce'], ['salesforce.com', 'Salesforce'], ['exacttarget.com', 'Salesforce Marketing Cloud'], ['hubspotemail.net', 'HubSpot'], ['hubspot.com', 'HubSpot'],
68 ['mail.zendesk.com', 'Zendesk'], ['zendesk.com', 'Zendesk'], ['freshdesk.com', 'Freshdesk'], ['helpscoutemail.com', 'Help Scout'], ['intercom.io', 'Intercom'],
69 ['mktomail.com', 'Marketo'], ['pardot.com', 'Salesforce Pardot'], ['sparkpostmail.com', 'SparkPost'], ['mailjet.com', 'Mailjet'], ['sendinblue.com', 'Brevo'], ['brevo.com', 'Brevo'],
70 ['mtasv.net', 'Postmark'], ['postmarkapp.com', 'Postmark'], ['klaviyo.com', 'Klaviyo'], ['constantcontact.com', 'Constant Contact'], ['createsend.com', 'Campaign Monitor'],
71 ['mlsend.com', 'MailerLite'], ['emsd1.com', 'ActiveCampaign'], ['atlassian.net', 'Atlassian'], ['docusign.net', 'DocuSign'], ['service-now.com', 'ServiceNow'],
72 ['greenhouse.io', 'Greenhouse'], ['shopify.com', 'Shopify'], ['qualtrics.com', 'Qualtrics'], ['smtp.com', 'SMTP.com'], ['mimecast.com', 'Mimecast'], ['pphosted.com', 'Proofpoint'],
73 ['sophos.com', 'Sophos'], ['mailchannels.net', 'MailChannels'], ['messagelabs.com', 'Symantec'], ['iphmx.com', 'Cisco Secure Email'], ['secureserver.net', 'GoDaddy'],
74 ['emailsrvr.com', 'Rackspace'], ['messagingengine.com', 'Fastmail'], ['protonmail.ch', 'Proton Mail'], ['icloud.com', 'Apple iCloud'], ['netsuite.com', 'Oracle NetSuite'],
75 ['oraclecloud.com', 'Oracle Cloud'], ['successfactors.com', 'SAP SuccessFactors'], ['workday.com', 'Workday'], ['freshworks.com', 'Freshworks'], ['gorgias.com', 'Gorgias'],
76 ['customer.io', 'Customer.io'], ['braze.com', 'Braze'], ['iterable.com', 'Iterable'], ['sendpulse.com', 'SendPulse'], ['elasticemail.com', 'Elastic Email'],
77];
78
79
80export const TXT_VERIFICATIONS = [
81 [/^ms=ms\d+/i, 'Microsoft 365'], [/^google-site-verification=/i, 'Google Search Console'], [/^facebook-domain-verification=/i, 'Meta Business'],
82 [/^atlassian-domain-verification=/i, 'Atlassian'], [/^docusign=/i, 'DocuSign'], [/^adobe-(idp-site|sign)-verification/i, 'Adobe'],
83 [/^apple-domain-verification=/i, 'Apple'], [/^openai-domain-verification=/i, 'OpenAI (ChatGPT Enterprise/Team)'], [/^anthropic-domain-verification/i, 'Anthropic (Claude)'],
84 [/^stripe-verification=/i, 'Stripe'], [/^(zoom-domain-verification|zoom_verify_)/i, 'Zoom'], [/^slack-domain-verification=/i, 'Slack'],
85 [/^hubspot-(developer|domain)-verification/i, 'HubSpot'], [/^miro-verification=/i, 'Miro'], [/^dropbox-domain-verification=/i, 'Dropbox'],
86 [/^cisco-ci-domain-verification=/i, 'Cisco Webex'], [/^onetrust-domain-verification=/i, 'OneTrust'], [/^knowbe4-site-verification=/i, 'KnowBe4'],
87 [/^mongodb-site-verification=/i, 'MongoDB Atlas'], [/^teamviewer-sso-verification=/i, 'TeamViewer'], [/^workplace-domain-verification=/i, 'Meta Workplace'],
88 [/^logmein-verification-code=/i, 'GoTo (LogMeIn)'], [/^citrix-verification-code=/i, 'Citrix'], [/^amazonses:/i, 'Amazon SES'], [/^yandex-verification:/i, 'Yandex'],
89 [/^jamf-site-verification=/i, 'Jamf'], [/^zapier-domain-verification/i, 'Zapier'], [/^pinterest-site-verification=/i, 'Pinterest'], [/^canva-site-verification=/i, 'Canva'],
90 [/^notion-domain-verification/i, 'Notion'], [/^asana-domain-verification/i, 'Asana'], [/^figma-domain-verification/i, 'Figma'], [/^okta-verification/i, 'Okta'],
91 [/^duo_sso_verification/i, 'Cisco Duo'], [/^1password-site-verification/i, '1Password'], [/^klaviyo-site-verification/i, 'Klaviyo'], [/^ahrefs-site-verification/i, 'Ahrefs'],
92 [/^segment-site-verification/i, 'Segment'], [/^postman-domain-verification/i, 'Postman'], [/^webexdomainverification/i, 'Cisco Webex'], [/^brevo-code:/i, 'Brevo'],
93 [/^sendinblue-code:/i, 'Brevo'], [/^mailru-verification:/i, 'Mail.ru'], [/^have-i-been-pwned-verification=/i, 'Have I Been Pwned'], [/^globalsign-domain-verification=/i, 'GlobalSign'],
94];
95
96export const CONSUMER_DOMAINS = new Set(['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'msn.com', 'yahoo.com', 'ymail.com', 'icloud.com', 'me.com', 'mac.com',
97 'aol.com', 'proton.me', 'protonmail.com', 'gmx.com', 'gmx.de', 'gmx.net', 'web.de', 'mail.ru', 'yandex.ru', 'yandex.com', 'qq.com', '163.com', '126.com', 'zoho.com', 'fastmail.com', 'hey.com']);
98
99
100const DOMAIN_RX = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/;
101
102export function toDomain(raw) {
103 let s = String(raw ?? '').trim().toLowerCase();
104 if (!s) return null;
105 let inputType = 'domain';
106 s = s.replace(/^mailto:/, '');
107 if (/^[^\s@/]+@[^\s@/]+$/.test(s)) { s = s.split('@').pop(); inputType = 'email'; }
108 else if (/^[a-z][a-z0-9+.-]*:\/\//.test(s) || s.includes('/')) {
109 try { s = new URL(/^[a-z][a-z0-9+.-]*:\/\//.test(s) ? s : `https://${s}`).hostname; inputType = 'url'; } catch { return null; }
110 }
111 s = s.replace(/\.$/, '').replace(/^www\d?\./, '');
112 try { s = new URL(`https://${s}`).hostname; } catch { return null; }
113 return DOMAIN_RX.test(s) ? { domain: s, inputType } : null;
114}
115
116
117export function classifyMx(mxRecords, domain = '') {
118 const sorted = [...(mxRecords || [])].map((r) => ({ exchange: host(r.exchange), priority: Number(r.priority) || 0 })).sort((a, b) => a.priority - b.priority);
119 const nullMx = sorted.length === 1 && (sorted[0].exchange === '' || sorted[0].exchange === '.');
120 const hosts = nullMx ? [] : sorted.map((r) => r.exchange).filter(Boolean);
121 let gateway = null; let provider = null;
122 for (const h of hosts) {
123 if (!gateway) { const g = GATEWAYS.find((x) => endsWithAny(h, x.mx)); if (g) gateway = g; }
124 if (!provider) { const p = MAILBOX_PROVIDERS.find((x) => endsWithAny(h, x.mx)); if (p) provider = p; }
125 }
126 if (!provider && hosts.length && hosts.every((h) => h === domain || h.endsWith(`.${domain}`))) provider = { id: 'self_hosted', name: 'Self-hosted / on-premises', espMatch: 'other', type: 'self_hosted' };
127 return { hosts, nullMx, gateway, provider };
128}
129
130
131export function pickSpf(txts) {
132 const recs = (txts || []).map((t) => (Array.isArray(t) ? t.join('') : String(t))).filter((t) => /^v=spf1(\s|$)/i.test(t.trim()));
133 return { record: recs[0] ?? null, count: recs.length };
134}
135export function parseSpf(record) {
136 const out = { includes: [], redirect: null, all: null, lookupMechanisms: 0, ip4: 0, ip6: 0 };
137 if (!record) return out;
138 for (const term of record.trim().split(/\s+/).slice(1)) {
139 const m = term.match(/^([+\-~?]?)([a-z0-9]+)(?:[:=](.*))?$/i);
140 if (!m) continue;
141 const [, q, mech, val] = m; const k = mech.toLowerCase();
142 if (k === 'include' && val) { out.includes.push(host(val)); out.lookupMechanisms++; }
143 else if (k === 'redirect' && val) { out.redirect = host(val); out.lookupMechanisms++; }
144 else if (k === 'a' || k === 'mx' || k === 'ptr' || k === 'exists') out.lookupMechanisms++;
145 else if (k === 'ip4') out.ip4++;
146 else if (k === 'ip6') out.ip6++;
147 else if (k === 'all') out.all = `${q || '+'}all`;
148 }
149 return out;
150}
151export function spfServices(domains) {
152 const found = new Set();
153 for (const d of domains) {
154 const h = host(d);
155 for (const [suffix, name] of SPF_SERVICES) if (h === suffix || h.endsWith(`.${suffix}`)) { found.add(name); break; }
156 }
157 return [...found];
158}
159
160
161export function parseDmarc(txts) {
162 const recs = (txts || []).map((t) => (Array.isArray(t) ? t.join('') : String(t))).filter((t) => /^v=dmarc1/i.test(t.trim()));
163 if (!recs.length) return { present: false, policy: null, subdomainPolicy: null, pct: null, hasRua: false, hasRuf: false, reportingDomains: [], multiple: false };
164 const tags = {};
165 for (const part of recs[0].split(';')) { const i = part.indexOf('='); if (i > 0) tags[part.slice(0, i).trim().toLowerCase()] = part.slice(i + 1).trim(); }
166 const domainsOf = (v) => (v || '').split(',').map((x) => x.trim().replace(/^mailto:/i, '').split('!')[0]).filter((x) => x.includes('@')).map((x) => x.split('@').pop().toLowerCase());
167 const p = (tags.p || '').toLowerCase();
168 return {
169 present: true,
170 policy: ['none', 'quarantine', 'reject'].includes(p) ? p : (p || 'invalid'),
171 subdomainPolicy: tags.sp ? tags.sp.toLowerCase() : null,
172 pct: tags.pct !== undefined && Number.isFinite(Number(tags.pct)) ? Number(tags.pct) : 100,
173 hasRua: !!tags.rua, hasRuf: !!tags.ruf,
174
175 reportingDomains: [...new Set([...domainsOf(tags.rua), ...domainsOf(tags.ruf)])],
176 multiple: recs.length > 1,
177 };
178}
179
180export const DMARC_VENDORS = [['dmarcian.com', 'dmarcian'], ['valimail.com', 'Valimail'], ['agari.com', 'Agari'], ['ondmarc.com', 'Red Sift OnDMARC'], ['redsift.cloud', 'Red Sift OnDMARC'],
181 ['easydmarc.us', 'EasyDMARC'], ['easydmarc.eu', 'EasyDMARC'], ['easydmarc.com', 'EasyDMARC'], ['dmarc-reports.cloudflare.net', 'Cloudflare DMARC Management'], ['uriports.com', 'URIports'],
182 ['postmarkapp.com', 'Postmark DMARC'], ['proofpoint.com', 'Proofpoint EFD'], ['emaildefense.proofpoint.com', 'Proofpoint EFD'], ['mimecast.com', 'Mimecast DMARC Analyzer'],
183 ['dmarcanalyzer.com', 'Mimecast DMARC Analyzer'], ['powerdmarc.com', 'PowerDMARC'], ['dmarcadvisor.com', 'DMARC Advisor'], ['sendmarc.com', 'Sendmarc'], ['dmarcly.com', 'DMARCLY'],
184 ['mxtoolbox.com', 'MxToolbox'], ['250ok.net', 'Validity'], ['returnpath.net', 'Validity'], ['fraudmarc.com', 'Fraudmarc'], ['glockapps.com', 'GlockApps'], ['dmarc.postmarkapp.com', 'Postmark DMARC']];
185export function dmarcVendors(domains) {
186 const out = new Set();
187 for (const d of domains) for (const [s, n] of DMARC_VENDORS) if (d === s || d.endsWith(`.${s}`)) { out.add(n); break; }
188 return [...out];
189}
190
191export function txtVerifications(txts) {
192 const out = new Set();
193 for (const t of txts || []) { const s = (Array.isArray(t) ? t.join('') : String(t)).trim(); for (const [rx, n] of TXT_VERIFICATIONS) if (rx.test(s)) { out.add(n); break; } }
194 return [...out];
195}
196
197
198export function inferUnderlying({ spfIncludes = [], dkimSelectors = [], verifications = [] }) {
199 const inc = spfIncludes.map(host);
200 const g = inc.some((h) => h === '_spf.google.com' || h.endsWith('.google.com')) || dkimSelectors.includes('google');
201 const m = inc.some((h) => h.endsWith('protection.outlook.com')) || dkimSelectors.includes('selector1') || dkimSelectors.includes('selector2');
202 if (g && !m) return MAILBOX_PROVIDERS[0];
203 if (m && !g) return MAILBOX_PROVIDERS[1];
204 if (m && g) return null;
205 if (verifications.includes('Microsoft 365')) return MAILBOX_PROVIDERS[1];
206 if (inc.some((h) => /(^|\.)zoho\.(com|eu|in)$/.test(h))) return MAILBOX_PROVIDERS[2];
207 return null;
208}
209
210
211
212export function isDkimRecord(txt) {
213 const s = String(txt);
214 if (/^v=(spf1|dmarc1)/i.test(s)) return false;
215 return /(^|;)\s*p=[A-Za-z0-9+/]{16,}/.test(s);
216}
217
218export function scoreEmailSecurity({ acceptsMail, spfCount, spf, spfLookups, dmarc, dkimSelectors, mtaSts, bimi }) {
219 let s = 0; const issues = [];
220 if (!acceptsMail) issues.push('Domain does not accept email (no MX or null MX).'); else s += 10;
221 if (spfCount === 0) issues.push('No SPF record: anyone can spoof this domain in the envelope sender.');
222 else if (spfCount > 1) issues.push(`${spfCount} SPF records found: receivers treat this as a permanent error. Merge them into one.`);
223 else {
224 s += 20;
225 if (spf.all === '+all') { s -= 15; issues.push('SPF ends in +all, which authorizes every server on the internet.'); }
226 else if (spf.all === '?all' || (!spf.all && !spf.redirect)) { issues.push(`SPF ends in ${spf.all || 'no all mechanism'}: neutral, gives receivers no instruction.`); }
227 else if (spf.all === '-all' || spf.all === '~all') s += 5;
228 if (Number.isFinite(spfLookups) && spfLookups > 10) { s -= 10; issues.push(`SPF needs ${spfLookups} DNS lookups (limit 10): SPF permerror, mail can fail authentication.`); }
229 }
230 if (!dmarc.present) issues.push('No DMARC record: no protection against exact-domain spoofing, and Gmail/Yahoo bulk-sender rules are not met.');
231 else {
232 s += 20;
233 if (dmarc.policy === 'reject') s += 25; else if (dmarc.policy === 'quarantine') s += 15;
234 else if (dmarc.policy === 'none') issues.push('DMARC policy is p=none (monitor only): spoofed mail is still delivered.');
235 else issues.push(`DMARC policy "${dmarc.policy}" is invalid.`);
236 if (dmarc.pct !== null && dmarc.pct < 100 && dmarc.policy !== 'none') issues.push(`DMARC pct=${dmarc.pct}: policy applies to only part of the mail.`); else if (dmarc.policy !== 'none') s += 5;
237 if (!dmarc.hasRua) issues.push('DMARC has no rua address, so no aggregate reports are collected.');
238 if (dmarc.multiple) issues.push('Multiple DMARC records: receivers may ignore DMARC entirely.');
239 }
240 if (dkimSelectors && dkimSelectors.length) s += 10;
241 if (mtaSts?.present) s += 5;
242 if (bimi?.present) s += 5;
243 s = Math.max(0, Math.min(100, s));
244 const grade = s >= 85 ? 'A' : s >= 70 ? 'B' : s >= 55 ? 'C' : s >= 35 ? 'D' : 'F';
245 return { score: s, grade, issues };
246}