@wraps.dev/cli 1.6.1 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +673 -0
- package/dist/cli.js +2621 -204
- package/dist/cli.js.map +1 -1
- package/dist/lambda/event-processor/.bundled +1 -1
- package/dist/lambda/sms-event-processor/.bundled +1 -1
- package/package.json +16 -17
package/dist/cli.js
CHANGED
|
@@ -147,7 +147,7 @@ var require_package = __commonJS({
|
|
|
147
147
|
"package.json"(exports, module) {
|
|
148
148
|
module.exports = {
|
|
149
149
|
name: "@wraps.dev/cli",
|
|
150
|
-
version: "1.6.
|
|
150
|
+
version: "1.6.3",
|
|
151
151
|
description: "CLI for deploying Wraps email infrastructure to your AWS account",
|
|
152
152
|
type: "module",
|
|
153
153
|
main: "./dist/cli.js",
|
|
@@ -194,7 +194,6 @@ var require_package = __commonJS({
|
|
|
194
194
|
author: "Wraps",
|
|
195
195
|
license: "MIT",
|
|
196
196
|
dependencies: {
|
|
197
|
-
"@wraps/email-check": "workspace:*",
|
|
198
197
|
"@aws-sdk/client-acm": "3.933.0",
|
|
199
198
|
"@aws-sdk/client-cloudformation": "^3.490.0",
|
|
200
199
|
"@aws-sdk/client-cloudfront": "3.933.0",
|
|
@@ -229,6 +228,7 @@ var require_package = __commonJS({
|
|
|
229
228
|
uuid: "^11.0.3"
|
|
230
229
|
},
|
|
231
230
|
devDependencies: {
|
|
231
|
+
"@wraps/email-check": "workspace:*",
|
|
232
232
|
"@types/args": "5.0.4",
|
|
233
233
|
"@types/express": "^5.0.0",
|
|
234
234
|
"@types/mailparser": "3.4.6",
|
|
@@ -4601,221 +4601,2638 @@ function buildConsolePolicyDocument(emailConfig, smsConfig) {
|
|
|
4601
4601
|
|
|
4602
4602
|
// src/commands/email/check.ts
|
|
4603
4603
|
init_esm_shims();
|
|
4604
|
-
init_events();
|
|
4605
4604
|
import { GetEmailIdentityCommand, SESv2Client } from "@aws-sdk/client-sesv2";
|
|
4606
4605
|
import * as clack3 from "@clack/prompts";
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
} from "
|
|
4612
|
-
import
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4606
|
+
|
|
4607
|
+
// ../email-check/dist/index.js
|
|
4608
|
+
init_esm_shims();
|
|
4609
|
+
import { promises as dns, Resolver } from "dns";
|
|
4610
|
+
import { URL } from "url";
|
|
4611
|
+
import * as net from "net";
|
|
4612
|
+
import * as tls from "tls";
|
|
4613
|
+
var QUICK_DKIM_SELECTORS = [
|
|
4614
|
+
// Top providers (most likely to find)
|
|
4615
|
+
"google",
|
|
4616
|
+
"selector1",
|
|
4617
|
+
"selector2",
|
|
4618
|
+
"default",
|
|
4619
|
+
"dkim",
|
|
4620
|
+
"mail",
|
|
4621
|
+
"email",
|
|
4622
|
+
"s1",
|
|
4623
|
+
"s2",
|
|
4624
|
+
"k1",
|
|
4625
|
+
// ESPs
|
|
4626
|
+
"sendgrid",
|
|
4627
|
+
"amazonses",
|
|
4628
|
+
"mandrill",
|
|
4629
|
+
"mailgun",
|
|
4630
|
+
"postmark",
|
|
4631
|
+
"resend",
|
|
4632
|
+
"ses",
|
|
4633
|
+
"sg",
|
|
4634
|
+
"mg",
|
|
4635
|
+
"pm",
|
|
4636
|
+
// Fallbacks
|
|
4637
|
+
"smtp",
|
|
4638
|
+
"mta",
|
|
4639
|
+
"mx",
|
|
4640
|
+
"primary",
|
|
4641
|
+
"main"
|
|
4642
|
+
];
|
|
4643
|
+
var QUICK_BLACKLISTS = [
|
|
4644
|
+
{ name: "Spamhaus ZEN", zone: "zen.spamhaus.org", priority: "critical" },
|
|
4645
|
+
{ name: "Spamhaus DBL", zone: "dbl.spamhaus.org", priority: "critical" },
|
|
4646
|
+
{ name: "Barracuda", zone: "b.barracudacentral.org", priority: "high" },
|
|
4647
|
+
{ name: "SpamCop", zone: "bl.spamcop.net", priority: "high" },
|
|
4648
|
+
{ name: "CBL", zone: "cbl.abuseat.org", priority: "high" },
|
|
4649
|
+
{ name: "SORBS", zone: "dnsbl.sorbs.net", priority: "medium" },
|
|
4650
|
+
{ name: "URIBL", zone: "multi.uribl.com", priority: "medium" },
|
|
4651
|
+
{ name: "SURBL", zone: "multi.surbl.org", priority: "medium" },
|
|
4652
|
+
{ name: "Mailspike", zone: "bl.mailspike.net", priority: "medium" },
|
|
4653
|
+
{ name: "Invaluement", zone: "dnsbl.invaluement.com", priority: "medium" }
|
|
4654
|
+
];
|
|
4655
|
+
var DEFAULT_DKIM_SELECTORS = [
|
|
4656
|
+
// Generic / common patterns
|
|
4657
|
+
"default",
|
|
4658
|
+
"dkim",
|
|
4659
|
+
"mail",
|
|
4660
|
+
"email",
|
|
4661
|
+
"smtp",
|
|
4662
|
+
"mta",
|
|
4663
|
+
"mx",
|
|
4664
|
+
"selector",
|
|
4665
|
+
"selector1",
|
|
4666
|
+
"selector2",
|
|
4667
|
+
"s",
|
|
4668
|
+
"s1",
|
|
4669
|
+
"s2",
|
|
4670
|
+
"s3",
|
|
4671
|
+
"k",
|
|
4672
|
+
"k1",
|
|
4673
|
+
"k2",
|
|
4674
|
+
"k3",
|
|
4675
|
+
"d",
|
|
4676
|
+
"d1",
|
|
4677
|
+
"d2",
|
|
4678
|
+
"m",
|
|
4679
|
+
"m1",
|
|
4680
|
+
"m2",
|
|
4681
|
+
"e",
|
|
4682
|
+
"e1",
|
|
4683
|
+
"e2",
|
|
4684
|
+
// Google Workspace
|
|
4685
|
+
"google",
|
|
4686
|
+
"g",
|
|
4687
|
+
"gm",
|
|
4688
|
+
// Microsoft 365 (selector1, selector2 already covered)
|
|
4689
|
+
// Amazon SES (note: often random, may not find)
|
|
4690
|
+
"amazonses",
|
|
4691
|
+
"ses",
|
|
4692
|
+
// Mailgun
|
|
4693
|
+
"mailo",
|
|
4694
|
+
"pic",
|
|
4695
|
+
"mg",
|
|
4696
|
+
"mailgun",
|
|
4697
|
+
// SendGrid
|
|
4698
|
+
"sendgrid",
|
|
4699
|
+
"sg",
|
|
4700
|
+
"smtpapi",
|
|
4701
|
+
// Mailchimp / Mandrill
|
|
4702
|
+
"mandrill",
|
|
4703
|
+
"mailchimp",
|
|
4704
|
+
"mc",
|
|
4705
|
+
"mte1",
|
|
4706
|
+
"mte2",
|
|
4707
|
+
// HubSpot
|
|
4708
|
+
"hs1",
|
|
4709
|
+
"hs2",
|
|
4710
|
+
"hubspot",
|
|
4711
|
+
"hubspotemail",
|
|
4712
|
+
// Klaviyo
|
|
4713
|
+
"kl",
|
|
4714
|
+
"kl2",
|
|
4715
|
+
"klaviyo",
|
|
4716
|
+
// Intercom
|
|
4717
|
+
"intercom",
|
|
4718
|
+
"ic",
|
|
4719
|
+
// Zendesk
|
|
4720
|
+
"zendesk",
|
|
4721
|
+
"zendesk1",
|
|
4722
|
+
"zendesk2",
|
|
4723
|
+
"zd",
|
|
4724
|
+
// Salesforce
|
|
4725
|
+
"sf",
|
|
4726
|
+
"sf1",
|
|
4727
|
+
"sf2",
|
|
4728
|
+
"salesforce",
|
|
4729
|
+
// Zoho
|
|
4730
|
+
"zoho",
|
|
4731
|
+
"zmail",
|
|
4732
|
+
"zm",
|
|
4733
|
+
// Fastmail
|
|
4734
|
+
"fm1",
|
|
4735
|
+
"fm2",
|
|
4736
|
+
"fm3",
|
|
4737
|
+
"mesmtp",
|
|
4738
|
+
// Proton
|
|
4739
|
+
"protonmail",
|
|
4740
|
+
"protonmail2",
|
|
4741
|
+
"protonmail3",
|
|
4742
|
+
// Mailerlite
|
|
4743
|
+
"ml",
|
|
4744
|
+
"litesrv",
|
|
4745
|
+
"mailerlite",
|
|
4746
|
+
// ConvertKit
|
|
4747
|
+
"ck",
|
|
4748
|
+
"ck1",
|
|
4749
|
+
"ck2",
|
|
4750
|
+
"convertkit",
|
|
4751
|
+
// ActiveCampaign
|
|
4752
|
+
"ac",
|
|
4753
|
+
"ac1",
|
|
4754
|
+
"ac2",
|
|
4755
|
+
"dk",
|
|
4756
|
+
"activecampaign",
|
|
4757
|
+
// Customer.io
|
|
4758
|
+
"cio",
|
|
4759
|
+
"customerio",
|
|
4760
|
+
// Postmark
|
|
4761
|
+
"pm",
|
|
4762
|
+
"postmark",
|
|
4763
|
+
// SparkPost
|
|
4764
|
+
"sparkpost",
|
|
4765
|
+
"sp",
|
|
4766
|
+
"sp1",
|
|
4767
|
+
"sp2",
|
|
4768
|
+
// SendPulse
|
|
4769
|
+
"sendpulse",
|
|
4770
|
+
// MailerSend
|
|
4771
|
+
"ms",
|
|
4772
|
+
"mailersend",
|
|
4773
|
+
// Loops
|
|
4774
|
+
"loops",
|
|
4775
|
+
// Resend
|
|
4776
|
+
"resend",
|
|
4777
|
+
// Campaign Monitor
|
|
4778
|
+
"cm",
|
|
4779
|
+
"cmail",
|
|
4780
|
+
// Drip
|
|
4781
|
+
"drip",
|
|
4782
|
+
// Brevo (Sendinblue)
|
|
4783
|
+
"sibmail",
|
|
4784
|
+
"brevo",
|
|
4785
|
+
// Mailjet
|
|
4786
|
+
"mailjet",
|
|
4787
|
+
"mj",
|
|
4788
|
+
// Constant Contact
|
|
4789
|
+
"cc",
|
|
4790
|
+
"ctct",
|
|
4791
|
+
"ctct1",
|
|
4792
|
+
"ctct2",
|
|
4793
|
+
// AWeber
|
|
4794
|
+
"aweber",
|
|
4795
|
+
"aw",
|
|
4796
|
+
// GetResponse
|
|
4797
|
+
"getresponse",
|
|
4798
|
+
"gr",
|
|
4799
|
+
// Moosend
|
|
4800
|
+
"moosend",
|
|
4801
|
+
// Omnisend
|
|
4802
|
+
"omnisend",
|
|
4803
|
+
// Keap / Infusionsoft
|
|
4804
|
+
"infusionsoft",
|
|
4805
|
+
"keap",
|
|
4806
|
+
// Twilio SendGrid
|
|
4807
|
+
"twilio",
|
|
4808
|
+
// Elastic Email
|
|
4809
|
+
"elastic",
|
|
4810
|
+
"ee",
|
|
4811
|
+
// Pepipost
|
|
4812
|
+
"pepipost",
|
|
4813
|
+
// SMTP.com
|
|
4814
|
+
"smtpcom",
|
|
4815
|
+
// Generic fallbacks
|
|
4816
|
+
"primary",
|
|
4817
|
+
"main",
|
|
4818
|
+
"prod",
|
|
4819
|
+
"live",
|
|
4820
|
+
"api",
|
|
4821
|
+
"bulk",
|
|
4822
|
+
"transactional",
|
|
4823
|
+
"marketing"
|
|
4824
|
+
];
|
|
4825
|
+
var DOMAIN_BLACKLISTS = [
|
|
4826
|
+
{ name: "Spamhaus DBL", zone: "dbl.spamhaus.org", priority: "critical" },
|
|
4827
|
+
{ name: "SURBL Multi", zone: "multi.surbl.org", priority: "high" },
|
|
4828
|
+
{ name: "URIBL Black", zone: "black.uribl.com", priority: "high" },
|
|
4829
|
+
{ name: "URIBL Grey", zone: "grey.uribl.com", priority: "medium" },
|
|
4830
|
+
{ name: "URIBL Red", zone: "red.uribl.com", priority: "high" },
|
|
4831
|
+
{ name: "URIBL Multi", zone: "multi.uribl.com", priority: "high" },
|
|
4832
|
+
{ name: "Spamcop URI", zone: "bl.spamcop.net", priority: "high" },
|
|
4833
|
+
{ name: "DBL Abuse.ch", zone: "dbl.abuse.ch", priority: "medium" },
|
|
4834
|
+
{ name: "FRESH URI", zone: "fresh.spameatingmonkey.net", priority: "low" },
|
|
4835
|
+
{ name: "Mailspike Z", zone: "z.mailspike.net", priority: "medium" },
|
|
4836
|
+
{ name: "SEM Fresh", zone: "fresh.spameatingmonkey.net", priority: "low" },
|
|
4837
|
+
{ name: "SEM URI", zone: "uribl.spameatingmonkey.net", priority: "medium" }
|
|
4838
|
+
];
|
|
4839
|
+
var IP_BLACKLISTS = [
|
|
4840
|
+
// Spamhaus (most important)
|
|
4841
|
+
{ name: "Spamhaus ZEN", zone: "zen.spamhaus.org", priority: "critical" },
|
|
4842
|
+
{ name: "Spamhaus SBL", zone: "sbl.spamhaus.org", priority: "critical" },
|
|
4843
|
+
{ name: "Spamhaus XBL", zone: "xbl.spamhaus.org", priority: "critical" },
|
|
4844
|
+
{ name: "Spamhaus PBL", zone: "pbl.spamhaus.org", priority: "high" },
|
|
4845
|
+
{ name: "Spamhaus CSS", zone: "sbl-xbl.spamhaus.org", priority: "critical" },
|
|
4846
|
+
// Major lists
|
|
4847
|
+
{ name: "Barracuda", zone: "b.barracudacentral.org", priority: "high" },
|
|
4848
|
+
{ name: "SpamCop", zone: "bl.spamcop.net", priority: "high" },
|
|
4849
|
+
{ name: "CBL", zone: "cbl.abuseat.org", priority: "high" },
|
|
4850
|
+
// Secondary lists
|
|
4851
|
+
{ name: "SORBS", zone: "dnsbl.sorbs.net", priority: "medium" },
|
|
4852
|
+
{ name: "SORBS Spam", zone: "spam.dnsbl.sorbs.net", priority: "medium" },
|
|
4853
|
+
{
|
|
4854
|
+
name: "SORBS Recent",
|
|
4855
|
+
zone: "recent.spam.dnsbl.sorbs.net",
|
|
4856
|
+
priority: "medium"
|
|
4857
|
+
},
|
|
4858
|
+
{ name: "SORBS Web", zone: "web.dnsbl.sorbs.net", priority: "medium" },
|
|
4859
|
+
{ name: "SORBS New", zone: "new.spam.dnsbl.sorbs.net", priority: "low" },
|
|
4860
|
+
{ name: "Passive Spam Block", zone: "psbl.surriel.com", priority: "medium" },
|
|
4861
|
+
{ name: "UCEPROTECT 1", zone: "dnsbl-1.uceprotect.net", priority: "medium" },
|
|
4862
|
+
{ name: "UCEPROTECT 2", zone: "dnsbl-2.uceprotect.net", priority: "low" },
|
|
4863
|
+
{ name: "UCEPROTECT 3", zone: "dnsbl-3.uceprotect.net", priority: "low" },
|
|
4864
|
+
{ name: "WPBL", zone: "db.wpbl.info", priority: "medium" },
|
|
4865
|
+
{ name: "Mailspike BL", zone: "bl.mailspike.net", priority: "medium" },
|
|
4866
|
+
{ name: "Mailspike Z", zone: "z.mailspike.net", priority: "medium" },
|
|
4867
|
+
// Additional lists
|
|
4868
|
+
{ name: "JustSpam", zone: "dnsbl.justspam.org", priority: "low" },
|
|
4869
|
+
{
|
|
4870
|
+
name: "Hostkarma Black",
|
|
4871
|
+
zone: "hostkarma.junkemailfilter.com",
|
|
4872
|
+
priority: "low"
|
|
4873
|
+
},
|
|
4874
|
+
{ name: "Invaluement", zone: "dnsbl.invaluement.com", priority: "medium" },
|
|
4875
|
+
{ name: "Truncate", zone: "truncate.gbudb.net", priority: "low" },
|
|
4876
|
+
{ name: "SpamRATS NoPtr", zone: "noptr.spamrats.com", priority: "low" },
|
|
4877
|
+
{ name: "SpamRATS Dyna", zone: "dyna.spamrats.com", priority: "low" },
|
|
4878
|
+
{ name: "SpamRATS Auth", zone: "auth.spamrats.com", priority: "low" },
|
|
4879
|
+
{ name: "SpamRATS Spam", zone: "spam.spamrats.com", priority: "low" },
|
|
4880
|
+
{ name: "BlockList.de", zone: "bl.blocklist.de", priority: "medium" },
|
|
4881
|
+
{ name: "DroneBL", zone: "dnsbl.dronebl.org", priority: "low" },
|
|
4882
|
+
{ name: "InterServer", zone: "rbl.interserver.net", priority: "low" },
|
|
4883
|
+
// Reputation lists
|
|
4884
|
+
{ name: "NiX Spam", zone: "ix.dnsbl.manitu.net", priority: "low" },
|
|
4885
|
+
{ name: "Composite BL", zone: "cbl.anti-spam.org.cn", priority: "low" }
|
|
4886
|
+
];
|
|
4887
|
+
var DEFAULT_TIMEOUT2 = 5e3;
|
|
4888
|
+
var SPF_LOOKUP_LIMIT = 10;
|
|
4889
|
+
var DKIM_BATCH_SIZE = 10;
|
|
4890
|
+
var BLACKLIST_BATCH_SIZE = 20;
|
|
4891
|
+
var SPAMHAUS_RETURN_CODES = {
|
|
4892
|
+
"127.0.0.2": "SBL - Spamhaus Block List (known spam source)",
|
|
4893
|
+
"127.0.0.3": "SBL - Spamhaus Block List CSS (snowshoe spam)",
|
|
4894
|
+
"127.0.0.4": "XBL - CBL (hijacked machine, compromised)",
|
|
4895
|
+
"127.0.0.5": "XBL - Njabl (open relay)",
|
|
4896
|
+
"127.0.0.6": "XBL - Njabl (spam source)",
|
|
4897
|
+
"127.0.0.9": "SBL - Spamhaus DROP (hijacked space)",
|
|
4898
|
+
"127.0.0.10": "PBL - Spamhaus Policy Block List (dynamic IP)",
|
|
4899
|
+
"127.0.0.11": "PBL - Spamhaus Policy Block List (ISP maintained)"
|
|
4900
|
+
};
|
|
4901
|
+
function createNodeDnsProvider(options = {}) {
|
|
4902
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT2;
|
|
4903
|
+
const resolver = new Resolver();
|
|
4904
|
+
if (options.servers && options.servers.length > 0) {
|
|
4905
|
+
resolver.setServers(options.servers);
|
|
4906
|
+
}
|
|
4907
|
+
async function withTimeout(promise, operation) {
|
|
4908
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
4909
|
+
setTimeout(() => {
|
|
4910
|
+
reject(new Error(`DNS ${operation} timed out after ${timeout}ms`));
|
|
4911
|
+
}, timeout);
|
|
4626
4912
|
});
|
|
4627
|
-
|
|
4628
|
-
clack3.cancel("Operation cancelled");
|
|
4629
|
-
process.exit(0);
|
|
4630
|
-
}
|
|
4631
|
-
domain = input;
|
|
4632
|
-
}
|
|
4633
|
-
if (!options.json) {
|
|
4634
|
-
clack3.intro(pc4.bold("Wraps Email Check"));
|
|
4635
|
-
console.log();
|
|
4636
|
-
}
|
|
4637
|
-
const spinner4 = options.json ? null : clack3.spinner();
|
|
4638
|
-
spinner4?.start(`Checking ${pc4.cyan(domain)}...`);
|
|
4639
|
-
let dkimSelectors;
|
|
4640
|
-
if (!options.dkimSelector) {
|
|
4641
|
-
const sesTokens = await tryGetSesDkimTokens(domain);
|
|
4642
|
-
if (sesTokens.length > 0) {
|
|
4643
|
-
dkimSelectors = sesTokens;
|
|
4644
|
-
}
|
|
4913
|
+
return Promise.race([promise, timeoutPromise]);
|
|
4645
4914
|
}
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
if (options.json) {
|
|
4659
|
-
console.log(JSON.stringify(result, null, 2));
|
|
4660
|
-
} else {
|
|
4661
|
-
displayResults(result, options);
|
|
4662
|
-
}
|
|
4663
|
-
const duration = Date.now() - startTime;
|
|
4664
|
-
trackCommand("email:check", {
|
|
4665
|
-
success: true,
|
|
4666
|
-
duration_ms: duration,
|
|
4667
|
-
grade: result.score.grade
|
|
4668
|
-
});
|
|
4669
|
-
process.exit(getExitCode(result.score.grade));
|
|
4670
|
-
} catch (error) {
|
|
4671
|
-
spinner4?.stop("Check failed");
|
|
4672
|
-
if (options.json) {
|
|
4673
|
-
console.log(JSON.stringify({ error: error.message }));
|
|
4674
|
-
} else {
|
|
4675
|
-
clack3.log.error(error.message);
|
|
4915
|
+
async function resolveTxt(domain) {
|
|
4916
|
+
try {
|
|
4917
|
+
const records = await withTimeout(
|
|
4918
|
+
dns.resolveTxt(domain),
|
|
4919
|
+
`TXT lookup for ${domain}`
|
|
4920
|
+
);
|
|
4921
|
+
return records;
|
|
4922
|
+
} catch (error) {
|
|
4923
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND") {
|
|
4924
|
+
return [];
|
|
4925
|
+
}
|
|
4926
|
+
throw error;
|
|
4676
4927
|
}
|
|
4677
|
-
const duration = Date.now() - startTime;
|
|
4678
|
-
trackCommand("email:check", {
|
|
4679
|
-
success: false,
|
|
4680
|
-
duration_ms: duration,
|
|
4681
|
-
error: error.message
|
|
4682
|
-
});
|
|
4683
|
-
process.exit(4);
|
|
4684
4928
|
}
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
console.log();
|
|
4691
|
-
console.log(pc4.bold("AUTHENTICATION"));
|
|
4692
|
-
console.log();
|
|
4693
|
-
displaySpfResult(spf, options.verbose);
|
|
4694
|
-
displayDkimResult(dkim, options.verbose);
|
|
4695
|
-
displayDmarcResult(dmarc);
|
|
4696
|
-
console.log();
|
|
4697
|
-
console.log(pc4.bold("INFRASTRUCTURE"));
|
|
4698
|
-
console.log();
|
|
4699
|
-
displayMxResult(mx);
|
|
4700
|
-
displayMxTlsResult(result);
|
|
4701
|
-
displayReverseDnsResult(result);
|
|
4702
|
-
displayIpv6Result(result);
|
|
4703
|
-
console.log();
|
|
4704
|
-
console.log(pc4.bold("REPUTATION"));
|
|
4705
|
-
console.log();
|
|
4706
|
-
displayBlacklistResult(blacklist, options.quick);
|
|
4707
|
-
displayDomainAgeResult(result);
|
|
4708
|
-
if (result.dnssec.enabled || result.caa.configured || result.mtaSts.configured || result.tlsRpt.configured) {
|
|
4709
|
-
console.log();
|
|
4710
|
-
console.log(pc4.bold("SECURITY"));
|
|
4711
|
-
console.log();
|
|
4712
|
-
if (result.dnssec.enabled) {
|
|
4713
|
-
const status2 = result.dnssec.valid ? pc4.green("\u2713") : pc4.red("\u2717");
|
|
4714
|
-
console.log(
|
|
4715
|
-
` ${status2} ${pc4.dim("DNSSEC")} ${result.dnssec.valid ? "Enabled and validated" : "Broken"}`
|
|
4716
|
-
);
|
|
4717
|
-
} else {
|
|
4718
|
-
console.log(
|
|
4719
|
-
` ${pc4.dim("\u25CB")} ${pc4.dim("DNSSEC")} Not configured`
|
|
4929
|
+
async function resolveMx(domain) {
|
|
4930
|
+
try {
|
|
4931
|
+
const records = await withTimeout(
|
|
4932
|
+
dns.resolveMx(domain),
|
|
4933
|
+
`MX lookup for ${domain}`
|
|
4720
4934
|
);
|
|
4935
|
+
return records.sort((a, b) => a.priority - b.priority);
|
|
4936
|
+
} catch (error) {
|
|
4937
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND") {
|
|
4938
|
+
return [];
|
|
4939
|
+
}
|
|
4940
|
+
throw error;
|
|
4721
4941
|
}
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
` ${pc4.dim("\u25CB")} ${pc4.dim("CAA")} Not configured`
|
|
4942
|
+
}
|
|
4943
|
+
async function resolveA(domain) {
|
|
4944
|
+
try {
|
|
4945
|
+
const records = await withTimeout(
|
|
4946
|
+
dns.resolve4(domain),
|
|
4947
|
+
`A lookup for ${domain}`
|
|
4729
4948
|
);
|
|
4949
|
+
return records;
|
|
4950
|
+
} catch (error) {
|
|
4951
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND") {
|
|
4952
|
+
return [];
|
|
4953
|
+
}
|
|
4954
|
+
throw error;
|
|
4730
4955
|
}
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
console.log(
|
|
4738
|
-
` ${pc4.dim("\u25CB")} ${pc4.dim("MTA-STS")} Not configured`
|
|
4956
|
+
}
|
|
4957
|
+
async function resolveAaaa(domain) {
|
|
4958
|
+
try {
|
|
4959
|
+
const records = await withTimeout(
|
|
4960
|
+
dns.resolve6(domain),
|
|
4961
|
+
`AAAA lookup for ${domain}`
|
|
4739
4962
|
);
|
|
4963
|
+
return records;
|
|
4964
|
+
} catch (error) {
|
|
4965
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND") {
|
|
4966
|
+
return [];
|
|
4967
|
+
}
|
|
4968
|
+
throw error;
|
|
4740
4969
|
}
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
` ${pc4.dim("\u25CB")} ${pc4.dim("TLS-RPT")} Not configured`
|
|
4970
|
+
}
|
|
4971
|
+
async function resolvePtr(ip) {
|
|
4972
|
+
try {
|
|
4973
|
+
const records = await withTimeout(
|
|
4974
|
+
dns.reverse(ip),
|
|
4975
|
+
`PTR lookup for ${ip}`
|
|
4748
4976
|
);
|
|
4977
|
+
return records;
|
|
4978
|
+
} catch (error) {
|
|
4979
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND" || error.code === "ENOENT") {
|
|
4980
|
+
return [];
|
|
4981
|
+
}
|
|
4982
|
+
throw error;
|
|
4749
4983
|
}
|
|
4750
4984
|
}
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4985
|
+
async function resolveCaa(domain) {
|
|
4986
|
+
try {
|
|
4987
|
+
const records = await withTimeout(
|
|
4988
|
+
dns.resolveCaa(domain),
|
|
4989
|
+
`CAA lookup for ${domain}`
|
|
4990
|
+
);
|
|
4991
|
+
return records.map((r) => {
|
|
4992
|
+
let tag = "issue";
|
|
4993
|
+
let value = "";
|
|
4994
|
+
if (r.issue !== void 0) {
|
|
4995
|
+
tag = "issue";
|
|
4996
|
+
value = r.issue;
|
|
4997
|
+
} else if (r.issuewild !== void 0) {
|
|
4998
|
+
tag = "issuewild";
|
|
4999
|
+
value = r.issuewild;
|
|
5000
|
+
} else if (r.iodef !== void 0) {
|
|
5001
|
+
tag = "iodef";
|
|
5002
|
+
value = r.iodef;
|
|
5003
|
+
}
|
|
5004
|
+
return {
|
|
5005
|
+
flags: r.critical,
|
|
5006
|
+
tag,
|
|
5007
|
+
value
|
|
5008
|
+
};
|
|
5009
|
+
});
|
|
5010
|
+
} catch (error) {
|
|
5011
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND") {
|
|
5012
|
+
return [];
|
|
4776
5013
|
}
|
|
5014
|
+
throw error;
|
|
4777
5015
|
}
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
5016
|
+
}
|
|
5017
|
+
async function resolveCname(domain) {
|
|
5018
|
+
try {
|
|
5019
|
+
const records = await withTimeout(
|
|
5020
|
+
dns.resolveCname(domain),
|
|
5021
|
+
`CNAME lookup for ${domain}`
|
|
5022
|
+
);
|
|
5023
|
+
return records;
|
|
5024
|
+
} catch (error) {
|
|
5025
|
+
if (error.code === "ENODATA" || error.code === "ENOTFOUND") {
|
|
5026
|
+
return [];
|
|
4785
5027
|
}
|
|
5028
|
+
throw error;
|
|
4786
5029
|
}
|
|
4787
5030
|
}
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
} else {
|
|
4798
|
-
console.log(
|
|
4799
|
-
pc4.yellow(
|
|
4800
|
-
"Need help fixing these? Deploy Wraps to manage your email infrastructure:"
|
|
4801
|
-
)
|
|
4802
|
-
);
|
|
4803
|
-
console.log();
|
|
4804
|
-
console.log(` ${pc4.cyan("npx @wraps.dev/cli email init")}`);
|
|
4805
|
-
}
|
|
4806
|
-
console.log();
|
|
4807
|
-
console.log(`Share: ${pc4.cyan(`https://wraps.dev/check/${result.domain}`)}`);
|
|
4808
|
-
console.log();
|
|
5031
|
+
return {
|
|
5032
|
+
resolveTxt,
|
|
5033
|
+
resolveMx,
|
|
5034
|
+
resolveA,
|
|
5035
|
+
resolveAaaa,
|
|
5036
|
+
resolvePtr,
|
|
5037
|
+
resolveCaa,
|
|
5038
|
+
resolveCname
|
|
5039
|
+
};
|
|
4809
5040
|
}
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
const
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
5041
|
+
var nodeDns = createNodeDnsProvider();
|
|
5042
|
+
var currentProvider = nodeDns;
|
|
5043
|
+
async function resolveTxtRecords(domain) {
|
|
5044
|
+
const records = await currentProvider.resolveTxt(domain);
|
|
5045
|
+
return records.map((parts) => parts.join(""));
|
|
5046
|
+
}
|
|
5047
|
+
async function resolveMxRecords(domain) {
|
|
5048
|
+
return currentProvider.resolveMx(domain);
|
|
5049
|
+
}
|
|
5050
|
+
async function resolveARecords(domain) {
|
|
5051
|
+
return currentProvider.resolveA(domain);
|
|
5052
|
+
}
|
|
5053
|
+
async function resolveAaaaRecords(domain) {
|
|
5054
|
+
return currentProvider.resolveAaaa(domain);
|
|
5055
|
+
}
|
|
5056
|
+
async function resolvePtrRecords(ip) {
|
|
5057
|
+
return currentProvider.resolvePtr(ip);
|
|
5058
|
+
}
|
|
5059
|
+
async function findSpfRecord(domain) {
|
|
5060
|
+
const txtRecords = await resolveTxtRecords(domain);
|
|
5061
|
+
return txtRecords.filter((r) => r.startsWith("v=spf1 ") || r === "v=spf1");
|
|
5062
|
+
}
|
|
5063
|
+
async function findDkimRecord(domain, selector) {
|
|
5064
|
+
const dkimDomain = `${selector}._domainkey.${domain}`;
|
|
5065
|
+
const txtRecords = await resolveTxtRecords(dkimDomain);
|
|
5066
|
+
const dkimRecord = txtRecords.find(
|
|
5067
|
+
(r) => r.startsWith("v=DKIM1") || r.includes("p=")
|
|
5068
|
+
);
|
|
5069
|
+
return dkimRecord || null;
|
|
5070
|
+
}
|
|
5071
|
+
async function findDmarcRecord(domain) {
|
|
5072
|
+
const dmarcDomain = `_dmarc.${domain}`;
|
|
5073
|
+
const txtRecords = await resolveTxtRecords(dmarcDomain);
|
|
5074
|
+
const dmarcRecord = txtRecords.find((r) => r.startsWith("v=DMARC1"));
|
|
5075
|
+
return dmarcRecord || null;
|
|
5076
|
+
}
|
|
5077
|
+
async function batchDnsQuery(items, queryFn, concurrency = 10) {
|
|
5078
|
+
const results = [];
|
|
5079
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
5080
|
+
const batch = items.slice(i, i + concurrency);
|
|
5081
|
+
const batchResults = await Promise.all(batch.map(queryFn));
|
|
5082
|
+
results.push(...batchResults);
|
|
5083
|
+
}
|
|
5084
|
+
return results;
|
|
5085
|
+
}
|
|
5086
|
+
function toAsciiDomain(domain) {
|
|
5087
|
+
try {
|
|
5088
|
+
const url = new URL(`http://${domain.toLowerCase()}`);
|
|
5089
|
+
return url.hostname;
|
|
5090
|
+
} catch {
|
|
5091
|
+
return domain.toLowerCase();
|
|
5092
|
+
}
|
|
5093
|
+
}
|
|
5094
|
+
function isValidDomain(domain) {
|
|
5095
|
+
if (!domain || domain.length > 253) return false;
|
|
5096
|
+
const asciiDomain = toAsciiDomain(domain);
|
|
5097
|
+
const labels = asciiDomain.split(".");
|
|
5098
|
+
if (labels.length < 2) return false;
|
|
5099
|
+
for (const label of labels) {
|
|
5100
|
+
if (!label || label.length > 63) return false;
|
|
5101
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(label)) return false;
|
|
5102
|
+
}
|
|
5103
|
+
return true;
|
|
5104
|
+
}
|
|
5105
|
+
function isIpAddress(str) {
|
|
5106
|
+
return isIpv4(str) || isIpv6(str);
|
|
5107
|
+
}
|
|
5108
|
+
function isIpv4(str) {
|
|
5109
|
+
const parts = str.split(".");
|
|
5110
|
+
if (parts.length !== 4) return false;
|
|
5111
|
+
return parts.every((part) => {
|
|
5112
|
+
const num = Number.parseInt(part, 10);
|
|
5113
|
+
return !Number.isNaN(num) && num >= 0 && num <= 255 && part === String(num);
|
|
5114
|
+
});
|
|
5115
|
+
}
|
|
5116
|
+
function isIpv6(str) {
|
|
5117
|
+
const ipv6Pattern = /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(?:ffff(?::0{1,4})?:)?(?:(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9])\.){3}(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9])\.){3}(?:25[0-5]|(?:2[0-4]|1?[0-9])?[0-9]))$/;
|
|
5118
|
+
return ipv6Pattern.test(str);
|
|
5119
|
+
}
|
|
5120
|
+
function reverseIp(ip) {
|
|
5121
|
+
if (isIpv4(ip)) {
|
|
5122
|
+
return ip.split(".").reverse().join(".");
|
|
5123
|
+
}
|
|
5124
|
+
throw new Error("IPv6 reverse not yet implemented");
|
|
5125
|
+
}
|
|
5126
|
+
function isLocalhost(ip) {
|
|
5127
|
+
if (isIpv4(ip)) {
|
|
5128
|
+
return ip === "127.0.0.1" || ip.startsWith("127.");
|
|
5129
|
+
}
|
|
5130
|
+
return ip === "::1" || ip === "0:0:0:0:0:0:0:1";
|
|
5131
|
+
}
|
|
5132
|
+
var TEST_RETURN_CODES = /* @__PURE__ */ new Set([
|
|
5133
|
+
"127.0.0.1",
|
|
5134
|
+
// Generic test response / query confirmation
|
|
5135
|
+
"127.0.1.255",
|
|
5136
|
+
// Spamhaus DBL test/error response
|
|
5137
|
+
"127.255.255.252",
|
|
5138
|
+
// DNS resolver test
|
|
5139
|
+
"127.255.255.254",
|
|
5140
|
+
// URIBL test response
|
|
5141
|
+
"127.255.255.255"
|
|
5142
|
+
// Reserved/test
|
|
5143
|
+
]);
|
|
5144
|
+
function isTestResponse(returnCode) {
|
|
5145
|
+
return TEST_RETURN_CODES.has(returnCode);
|
|
5146
|
+
}
|
|
5147
|
+
async function checkBlacklist(options) {
|
|
5148
|
+
const { quick = false, skip = false, domain, ips = [] } = options;
|
|
5149
|
+
const result = {
|
|
5150
|
+
domainChecks: {
|
|
5151
|
+
checked: 0,
|
|
5152
|
+
listed: [],
|
|
5153
|
+
clean: [],
|
|
5154
|
+
errors: [],
|
|
5155
|
+
timeouts: []
|
|
5156
|
+
},
|
|
5157
|
+
ipChecks: {
|
|
5158
|
+
checked: 0,
|
|
5159
|
+
listed: [],
|
|
5160
|
+
clean: [],
|
|
5161
|
+
errors: [],
|
|
5162
|
+
timeouts: []
|
|
5163
|
+
},
|
|
5164
|
+
overallClean: true,
|
|
5165
|
+
quickMode: quick
|
|
5166
|
+
};
|
|
5167
|
+
if (skip) {
|
|
5168
|
+
return result;
|
|
5169
|
+
}
|
|
5170
|
+
const domainBlacklists = quick ? QUICK_BLACKLISTS.slice(0, 5) : DOMAIN_BLACKLISTS;
|
|
5171
|
+
const ipBlacklists = quick ? QUICK_BLACKLISTS : IP_BLACKLISTS;
|
|
5172
|
+
await checkDomainBlacklists(domain, domainBlacklists, result);
|
|
5173
|
+
if (ips.length > 0) {
|
|
5174
|
+
await checkIpBlacklists(ips, ipBlacklists, result);
|
|
5175
|
+
}
|
|
5176
|
+
result.overallClean = result.domainChecks.listed.length === 0 && result.ipChecks.listed.length === 0;
|
|
5177
|
+
return result;
|
|
5178
|
+
}
|
|
5179
|
+
async function checkDomainBlacklists(domain, blacklists, result) {
|
|
5180
|
+
const checks = blacklists.map((bl) => ({
|
|
5181
|
+
blacklist: bl,
|
|
5182
|
+
query: `${domain}.${bl.zone}`
|
|
5183
|
+
}));
|
|
5184
|
+
await batchDnsQuery(
|
|
5185
|
+
checks,
|
|
5186
|
+
async (check2) => {
|
|
5187
|
+
result.domainChecks.checked++;
|
|
5188
|
+
try {
|
|
5189
|
+
const records = await resolveARecords(check2.query);
|
|
5190
|
+
const firstRecord = records[0];
|
|
5191
|
+
if (records.length > 0 && firstRecord && !isTestResponse(firstRecord)) {
|
|
5192
|
+
const listing = createListing(
|
|
5193
|
+
check2.blacklist,
|
|
5194
|
+
"domain",
|
|
5195
|
+
domain,
|
|
5196
|
+
firstRecord
|
|
5197
|
+
);
|
|
5198
|
+
result.domainChecks.listed.push(listing);
|
|
5199
|
+
result.overallClean = false;
|
|
5200
|
+
} else {
|
|
5201
|
+
result.domainChecks.clean.push(check2.blacklist.name);
|
|
5202
|
+
}
|
|
5203
|
+
} catch (error) {
|
|
5204
|
+
if (error.message?.includes("timed out")) {
|
|
5205
|
+
result.domainChecks.timeouts.push(check2.blacklist.name);
|
|
5206
|
+
} else if (error.code !== "ENOTFOUND" && error.code !== "ENODATA") {
|
|
5207
|
+
result.domainChecks.errors.push(
|
|
5208
|
+
`${check2.blacklist.name}: ${error.message}`
|
|
5209
|
+
);
|
|
5210
|
+
} else {
|
|
5211
|
+
result.domainChecks.clean.push(check2.blacklist.name);
|
|
5212
|
+
}
|
|
5213
|
+
}
|
|
5214
|
+
},
|
|
5215
|
+
BLACKLIST_BATCH_SIZE
|
|
5216
|
+
);
|
|
5217
|
+
}
|
|
5218
|
+
async function checkIpBlacklists(ips, blacklists, result) {
|
|
5219
|
+
const ipv4Ips = ips.filter(isIpv4);
|
|
5220
|
+
for (const ip of ipv4Ips) {
|
|
5221
|
+
const reversedIp = reverseIp(ip);
|
|
5222
|
+
const checks = blacklists.map((bl) => ({
|
|
5223
|
+
blacklist: bl,
|
|
5224
|
+
query: `${reversedIp}.${bl.zone}`,
|
|
5225
|
+
ip
|
|
5226
|
+
}));
|
|
5227
|
+
await batchDnsQuery(
|
|
5228
|
+
checks,
|
|
5229
|
+
async (check2) => {
|
|
5230
|
+
result.ipChecks.checked++;
|
|
5231
|
+
try {
|
|
5232
|
+
const records = await resolveARecords(check2.query);
|
|
5233
|
+
const firstRecord = records[0];
|
|
5234
|
+
if (records.length > 0 && firstRecord && !isTestResponse(firstRecord)) {
|
|
5235
|
+
const listing = createListing(
|
|
5236
|
+
check2.blacklist,
|
|
5237
|
+
"ip",
|
|
5238
|
+
check2.ip,
|
|
5239
|
+
firstRecord
|
|
5240
|
+
);
|
|
5241
|
+
result.ipChecks.listed.push(listing);
|
|
5242
|
+
result.overallClean = false;
|
|
5243
|
+
} else {
|
|
5244
|
+
result.ipChecks.clean.push(check2.blacklist.name);
|
|
5245
|
+
}
|
|
5246
|
+
} catch (error) {
|
|
5247
|
+
if (error.message?.includes("timed out")) {
|
|
5248
|
+
result.ipChecks.timeouts.push(check2.blacklist.name);
|
|
5249
|
+
} else if (error.code !== "ENOTFOUND" && error.code !== "ENODATA") {
|
|
5250
|
+
result.ipChecks.errors.push(
|
|
5251
|
+
`${check2.blacklist.name}: ${error.message}`
|
|
5252
|
+
);
|
|
5253
|
+
} else {
|
|
5254
|
+
result.ipChecks.clean.push(check2.blacklist.name);
|
|
5255
|
+
}
|
|
5256
|
+
}
|
|
5257
|
+
},
|
|
5258
|
+
BLACKLIST_BATCH_SIZE
|
|
5259
|
+
);
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
function createListing(blacklist, type, target, returnCode) {
|
|
5263
|
+
return {
|
|
5264
|
+
blacklist: blacklist.name,
|
|
5265
|
+
zone: blacklist.zone,
|
|
5266
|
+
priority: blacklist.priority || "medium",
|
|
5267
|
+
type,
|
|
5268
|
+
target,
|
|
5269
|
+
returnCode,
|
|
5270
|
+
meaning: getMeaning(blacklist.zone, returnCode),
|
|
5271
|
+
delistUrl: getDelistUrl(blacklist.zone)
|
|
5272
|
+
};
|
|
5273
|
+
}
|
|
5274
|
+
function getMeaning(zone, returnCode) {
|
|
5275
|
+
if (zone.includes("spamhaus")) {
|
|
5276
|
+
return SPAMHAUS_RETURN_CODES[returnCode] || `Listed (${returnCode})`;
|
|
5277
|
+
}
|
|
5278
|
+
if (returnCode === "127.0.0.2") {
|
|
5279
|
+
return "Listed as spam source";
|
|
5280
|
+
}
|
|
5281
|
+
if (returnCode === "127.0.0.3") {
|
|
5282
|
+
return "Listed as spam source (elevated threat)";
|
|
5283
|
+
}
|
|
5284
|
+
return `Listed (${returnCode})`;
|
|
5285
|
+
}
|
|
5286
|
+
function getDelistUrl(zone) {
|
|
5287
|
+
const delistUrls = {
|
|
5288
|
+
"zen.spamhaus.org": "https://check.spamhaus.org/",
|
|
5289
|
+
"dbl.spamhaus.org": "https://check.spamhaus.org/",
|
|
5290
|
+
"sbl.spamhaus.org": "https://check.spamhaus.org/",
|
|
5291
|
+
"xbl.spamhaus.org": "https://check.spamhaus.org/",
|
|
5292
|
+
"pbl.spamhaus.org": "https://check.spamhaus.org/",
|
|
5293
|
+
"b.barracudacentral.org": "https://www.barracudacentral.org/lookups",
|
|
5294
|
+
"bl.spamcop.net": "https://www.spamcop.net/bl.shtml",
|
|
5295
|
+
"cbl.abuseat.org": "https://cbl.abuseat.org/lookup.cgi",
|
|
5296
|
+
"dnsbl.sorbs.net": "http://www.sorbs.net/lookup.shtml"
|
|
5297
|
+
};
|
|
5298
|
+
return delistUrls[zone] || null;
|
|
5299
|
+
}
|
|
5300
|
+
async function checkDkim(domain, options = {}) {
|
|
5301
|
+
const {
|
|
5302
|
+
quick = false,
|
|
5303
|
+
selector,
|
|
5304
|
+
selectors: customSelectors,
|
|
5305
|
+
verbose = false,
|
|
5306
|
+
detectedProvider
|
|
5307
|
+
} = options;
|
|
5308
|
+
let selectorsToCheck;
|
|
5309
|
+
if (selector) {
|
|
5310
|
+
selectorsToCheck = [selector];
|
|
5311
|
+
} else if (customSelectors && customSelectors.length > 0) {
|
|
5312
|
+
selectorsToCheck = customSelectors;
|
|
5313
|
+
} else if (quick) {
|
|
5314
|
+
selectorsToCheck = [...QUICK_DKIM_SELECTORS];
|
|
5315
|
+
} else {
|
|
5316
|
+
selectorsToCheck = [...DEFAULT_DKIM_SELECTORS];
|
|
5317
|
+
}
|
|
5318
|
+
const result = {
|
|
5319
|
+
found: false,
|
|
5320
|
+
selectors: [],
|
|
5321
|
+
selectorsChecked: 0,
|
|
5322
|
+
earlyExit: false,
|
|
5323
|
+
warnings: []
|
|
5324
|
+
};
|
|
5325
|
+
let foundValid = false;
|
|
5326
|
+
for (let i = 0; i < selectorsToCheck.length; i += DKIM_BATCH_SIZE) {
|
|
5327
|
+
if (foundValid && !verbose) {
|
|
5328
|
+
result.earlyExit = true;
|
|
5329
|
+
break;
|
|
5330
|
+
}
|
|
5331
|
+
const batch = selectorsToCheck.slice(i, i + DKIM_BATCH_SIZE);
|
|
5332
|
+
const batchResults = await batchDnsQuery(
|
|
5333
|
+
batch,
|
|
5334
|
+
async (sel) => checkDkimSelector(domain, sel),
|
|
5335
|
+
DKIM_BATCH_SIZE
|
|
5336
|
+
);
|
|
5337
|
+
for (const selectorResult of batchResults) {
|
|
5338
|
+
result.selectorsChecked++;
|
|
5339
|
+
if (selectorResult.exists) {
|
|
5340
|
+
result.selectors.push(selectorResult);
|
|
5341
|
+
result.found = true;
|
|
5342
|
+
if (selectorResult.valid && !selectorResult.revoked) {
|
|
5343
|
+
foundValid = true;
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
5348
|
+
result.selectors.sort((a, b) => {
|
|
5349
|
+
const aScore = (a.valid ? 2 : 0) + (a.revoked ? 0 : 1);
|
|
5350
|
+
const bScore = (b.valid ? 2 : 0) + (b.revoked ? 0 : 1);
|
|
5351
|
+
return bScore - aScore;
|
|
5352
|
+
});
|
|
5353
|
+
if (!result.found && detectedProvider) {
|
|
5354
|
+
const randomSelectorProviders = {
|
|
5355
|
+
ses: "AWS SES uses random DKIM selectors. Check your SES console or use --dkimSelector with your actual selector.",
|
|
5356
|
+
amazonses: "AWS SES uses random DKIM selectors. Check your SES console or use --dkimSelector with your actual selector.",
|
|
5357
|
+
sendgrid: "SendGrid may use custom selectors. Check your SendGrid dashboard or use --dkimSelector.",
|
|
5358
|
+
mailgun: "Mailgun may use custom selectors. Check your Mailgun dashboard or use --dkimSelector."
|
|
5359
|
+
};
|
|
5360
|
+
const providerLower = detectedProvider.toLowerCase();
|
|
5361
|
+
for (const [key, message] of Object.entries(randomSelectorProviders)) {
|
|
5362
|
+
if (providerLower.includes(key)) {
|
|
5363
|
+
result.warnings.push(message);
|
|
5364
|
+
break;
|
|
5365
|
+
}
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5368
|
+
return result;
|
|
5369
|
+
}
|
|
5370
|
+
async function checkDkimSelector(domain, selector) {
|
|
5371
|
+
const result = {
|
|
5372
|
+
selector,
|
|
5373
|
+
exists: false,
|
|
5374
|
+
record: null,
|
|
5375
|
+
valid: false,
|
|
5376
|
+
keyType: null,
|
|
5377
|
+
keyBits: null,
|
|
5378
|
+
publicKey: null,
|
|
5379
|
+
testMode: false,
|
|
5380
|
+
revoked: false,
|
|
5381
|
+
expired: false,
|
|
5382
|
+
hashAlgorithms: [],
|
|
5383
|
+
serviceTypes: [],
|
|
5384
|
+
flags: [],
|
|
5385
|
+
errors: [],
|
|
5386
|
+
warnings: []
|
|
5387
|
+
};
|
|
5388
|
+
try {
|
|
5389
|
+
const record = await findDkimRecord(domain, selector);
|
|
5390
|
+
if (!record) {
|
|
5391
|
+
return result;
|
|
5392
|
+
}
|
|
5393
|
+
result.exists = true;
|
|
5394
|
+
result.record = record;
|
|
5395
|
+
parseDkimRecord(record, result);
|
|
5396
|
+
} catch (error) {
|
|
5397
|
+
result.errors.push(error.message);
|
|
5398
|
+
}
|
|
5399
|
+
return result;
|
|
5400
|
+
}
|
|
5401
|
+
function parseDkimRecord(record, result) {
|
|
5402
|
+
const tags = parseDkimTags(record);
|
|
5403
|
+
const version = tags.get("v");
|
|
5404
|
+
if (version && version !== "DKIM1") {
|
|
5405
|
+
result.errors.push(`Invalid DKIM version: ${version}`);
|
|
5406
|
+
}
|
|
5407
|
+
const keyType = tags.get("k") || "rsa";
|
|
5408
|
+
if (keyType === "rsa") {
|
|
5409
|
+
result.keyType = "rsa";
|
|
5410
|
+
} else if (keyType === "ed25519") {
|
|
5411
|
+
result.keyType = "ed25519";
|
|
5412
|
+
} else {
|
|
5413
|
+
result.keyType = "unknown";
|
|
5414
|
+
result.warnings.push(`Unknown key type: ${keyType}`);
|
|
5415
|
+
}
|
|
5416
|
+
const publicKey = tags.get("p");
|
|
5417
|
+
if (publicKey === void 0) {
|
|
5418
|
+
result.errors.push("Missing public key (p=)");
|
|
5419
|
+
return;
|
|
5420
|
+
}
|
|
5421
|
+
if (publicKey === "") {
|
|
5422
|
+
result.revoked = true;
|
|
5423
|
+
result.warnings.push("DKIM key is revoked (empty p= tag)");
|
|
5424
|
+
return;
|
|
5425
|
+
}
|
|
5426
|
+
result.publicKey = publicKey;
|
|
5427
|
+
if (result.keyType === "rsa") {
|
|
5428
|
+
try {
|
|
5429
|
+
const keyData = Buffer.from(publicKey, "base64");
|
|
5430
|
+
const estimatedBits = keyData.length * 8;
|
|
5431
|
+
result.keyBits = Math.round((estimatedBits - 256) * 0.95);
|
|
5432
|
+
if (result.keyBits < 512) result.keyBits = 512;
|
|
5433
|
+
if (result.keyBits > 4096) result.keyBits = 4096;
|
|
5434
|
+
if (result.keyBits <= 768) result.keyBits = 512;
|
|
5435
|
+
else if (result.keyBits <= 1200) result.keyBits = 1024;
|
|
5436
|
+
else if (result.keyBits <= 2500) result.keyBits = 2048;
|
|
5437
|
+
else if (result.keyBits <= 3500) result.keyBits = 3072;
|
|
5438
|
+
else result.keyBits = 4096;
|
|
5439
|
+
if (result.keyBits < 1024) {
|
|
5440
|
+
result.errors.push(`RSA key too weak: ${result.keyBits} bits`);
|
|
5441
|
+
} else if (result.keyBits < 2048) {
|
|
5442
|
+
result.warnings.push(
|
|
5443
|
+
`RSA key should be 2048+ bits (currently ${result.keyBits})`
|
|
5444
|
+
);
|
|
5445
|
+
}
|
|
5446
|
+
} catch {
|
|
5447
|
+
result.warnings.push("Could not determine RSA key size");
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
const hashAlg = tags.get("h");
|
|
5451
|
+
if (hashAlg) {
|
|
5452
|
+
result.hashAlgorithms = hashAlg.split(":").map((h) => h.trim());
|
|
5453
|
+
if (result.hashAlgorithms.includes("sha1") && !result.hashAlgorithms.includes("sha256")) {
|
|
5454
|
+
result.warnings.push("Only sha1 allowed - sha256 recommended");
|
|
5455
|
+
}
|
|
5456
|
+
} else {
|
|
5457
|
+
result.hashAlgorithms = ["sha1", "sha256"];
|
|
5458
|
+
}
|
|
5459
|
+
const serviceType = tags.get("s");
|
|
5460
|
+
if (serviceType) {
|
|
5461
|
+
result.serviceTypes = serviceType.split(":").map((s) => s.trim());
|
|
5462
|
+
if (!(result.serviceTypes.includes("*") || result.serviceTypes.includes("email"))) {
|
|
5463
|
+
result.warnings.push(
|
|
5464
|
+
`Service type restricts to: ${result.serviceTypes.join(", ")}`
|
|
5465
|
+
);
|
|
5466
|
+
}
|
|
5467
|
+
} else {
|
|
5468
|
+
result.serviceTypes = ["*"];
|
|
5469
|
+
}
|
|
5470
|
+
const flagsTag = tags.get("t");
|
|
5471
|
+
if (flagsTag) {
|
|
5472
|
+
result.flags = flagsTag.split(":").map((f) => f.trim());
|
|
5473
|
+
if (result.flags.includes("y")) {
|
|
5474
|
+
result.testMode = true;
|
|
5475
|
+
result.warnings.push("DKIM in testing mode (t=y)");
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
const notes = tags.get("n");
|
|
5479
|
+
if (notes) {
|
|
5480
|
+
result.warnings.push(`DKIM note: ${notes}`);
|
|
5481
|
+
}
|
|
5482
|
+
result.valid = result.errors.length === 0 && !result.revoked;
|
|
5483
|
+
}
|
|
5484
|
+
function parseDkimTags(record) {
|
|
5485
|
+
const tags = /* @__PURE__ */ new Map();
|
|
5486
|
+
const parts = record.split(";").map((p) => p.trim()).filter(Boolean);
|
|
5487
|
+
for (const part of parts) {
|
|
5488
|
+
const eqIndex = part.indexOf("=");
|
|
5489
|
+
if (eqIndex === -1) continue;
|
|
5490
|
+
const key = part.slice(0, eqIndex).trim().toLowerCase();
|
|
5491
|
+
const value = part.slice(eqIndex + 1).trim();
|
|
5492
|
+
tags.set(key, value);
|
|
5493
|
+
}
|
|
5494
|
+
return tags;
|
|
5495
|
+
}
|
|
5496
|
+
async function checkDmarc(domain) {
|
|
5497
|
+
const result = {
|
|
5498
|
+
exists: false,
|
|
5499
|
+
record: null,
|
|
5500
|
+
valid: false,
|
|
5501
|
+
policy: null,
|
|
5502
|
+
subdomainPolicy: null,
|
|
5503
|
+
percentage: 100,
|
|
5504
|
+
reportingEnabled: false,
|
|
5505
|
+
ruaAddresses: [],
|
|
5506
|
+
rufAddresses: [],
|
|
5507
|
+
alignmentSpf: "relaxed",
|
|
5508
|
+
alignmentDkim: "relaxed",
|
|
5509
|
+
failureOptions: "0",
|
|
5510
|
+
reportInterval: 86400,
|
|
5511
|
+
reportFormat: "afrf",
|
|
5512
|
+
errors: [],
|
|
5513
|
+
warnings: []
|
|
5514
|
+
};
|
|
5515
|
+
try {
|
|
5516
|
+
const record = await findDmarcRecord(domain);
|
|
5517
|
+
if (!record) {
|
|
5518
|
+
return result;
|
|
5519
|
+
}
|
|
5520
|
+
result.exists = true;
|
|
5521
|
+
result.record = record;
|
|
5522
|
+
parseDmarcRecord(record, result);
|
|
5523
|
+
} catch (error) {
|
|
5524
|
+
result.errors.push(error.message);
|
|
5525
|
+
}
|
|
5526
|
+
return result;
|
|
5527
|
+
}
|
|
5528
|
+
function parseDmarcRecord(record, result) {
|
|
5529
|
+
const tags = parseDmarcTags(record);
|
|
5530
|
+
const version = tags.get("v");
|
|
5531
|
+
if (version !== "DMARC1") {
|
|
5532
|
+
result.errors.push(`Invalid DMARC version: ${version || "missing"}`);
|
|
5533
|
+
return;
|
|
5534
|
+
}
|
|
5535
|
+
const policy = tags.get("p");
|
|
5536
|
+
if (!policy) {
|
|
5537
|
+
result.errors.push("Missing required policy (p=)");
|
|
5538
|
+
return;
|
|
5539
|
+
}
|
|
5540
|
+
if (!["none", "quarantine", "reject"].includes(policy)) {
|
|
5541
|
+
result.errors.push(`Invalid policy: ${policy}`);
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5544
|
+
result.policy = policy;
|
|
5545
|
+
const subdomainPolicy = tags.get("sp");
|
|
5546
|
+
if (subdomainPolicy) {
|
|
5547
|
+
if (["none", "quarantine", "reject"].includes(subdomainPolicy)) {
|
|
5548
|
+
result.subdomainPolicy = subdomainPolicy;
|
|
5549
|
+
} else {
|
|
5550
|
+
result.warnings.push(`Invalid subdomain policy: ${subdomainPolicy}`);
|
|
5551
|
+
}
|
|
5552
|
+
} else {
|
|
5553
|
+
result.subdomainPolicy = result.policy;
|
|
5554
|
+
}
|
|
5555
|
+
const pct = tags.get("pct");
|
|
5556
|
+
if (pct) {
|
|
5557
|
+
const pctNum = Number.parseInt(pct, 10);
|
|
5558
|
+
if (Number.isNaN(pctNum) || pctNum < 0 || pctNum > 100) {
|
|
5559
|
+
result.warnings.push(`Invalid percentage: ${pct}`);
|
|
5560
|
+
} else {
|
|
5561
|
+
result.percentage = pctNum;
|
|
5562
|
+
if (pctNum < 100) {
|
|
5563
|
+
result.warnings.push(`Only ${pctNum}% of messages subject to policy`);
|
|
5564
|
+
}
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
const rua = tags.get("rua");
|
|
5568
|
+
if (rua) {
|
|
5569
|
+
result.ruaAddresses = parseReportAddresses(rua);
|
|
5570
|
+
result.reportingEnabled = result.ruaAddresses.length > 0;
|
|
5571
|
+
}
|
|
5572
|
+
const ruf = tags.get("ruf");
|
|
5573
|
+
if (ruf) {
|
|
5574
|
+
result.rufAddresses = parseReportAddresses(ruf);
|
|
5575
|
+
}
|
|
5576
|
+
const aspf = tags.get("aspf");
|
|
5577
|
+
if (aspf) {
|
|
5578
|
+
if (aspf === "s") {
|
|
5579
|
+
result.alignmentSpf = "strict";
|
|
5580
|
+
} else if (aspf === "r") {
|
|
5581
|
+
result.alignmentSpf = "relaxed";
|
|
5582
|
+
} else {
|
|
5583
|
+
result.warnings.push(`Invalid SPF alignment mode: ${aspf}`);
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
5586
|
+
const adkim = tags.get("adkim");
|
|
5587
|
+
if (adkim) {
|
|
5588
|
+
if (adkim === "s") {
|
|
5589
|
+
result.alignmentDkim = "strict";
|
|
5590
|
+
} else if (adkim === "r") {
|
|
5591
|
+
result.alignmentDkim = "relaxed";
|
|
5592
|
+
} else {
|
|
5593
|
+
result.warnings.push(`Invalid DKIM alignment mode: ${adkim}`);
|
|
5594
|
+
}
|
|
5595
|
+
}
|
|
5596
|
+
const fo = tags.get("fo");
|
|
5597
|
+
if (fo) {
|
|
5598
|
+
const validOptions = ["0", "1", "d", "s"];
|
|
5599
|
+
const options = fo.split(":").map((o) => o.trim());
|
|
5600
|
+
for (const opt of options) {
|
|
5601
|
+
if (!validOptions.includes(opt)) {
|
|
5602
|
+
result.warnings.push(`Unknown failure option: ${opt}`);
|
|
5603
|
+
}
|
|
5604
|
+
}
|
|
5605
|
+
result.failureOptions = fo;
|
|
5606
|
+
}
|
|
5607
|
+
const ri = tags.get("ri");
|
|
5608
|
+
if (ri) {
|
|
5609
|
+
const riNum = Number.parseInt(ri, 10);
|
|
5610
|
+
if (Number.isNaN(riNum) || riNum < 0) {
|
|
5611
|
+
result.warnings.push(`Invalid report interval: ${ri}`);
|
|
5612
|
+
} else {
|
|
5613
|
+
result.reportInterval = riNum;
|
|
5614
|
+
}
|
|
5615
|
+
}
|
|
5616
|
+
const rf = tags.get("rf");
|
|
5617
|
+
if (rf) {
|
|
5618
|
+
result.reportFormat = rf;
|
|
5619
|
+
}
|
|
5620
|
+
if (result.policy === "none") {
|
|
5621
|
+
result.warnings.push(
|
|
5622
|
+
'DMARC policy is "none" - not enforcing authentication'
|
|
5623
|
+
);
|
|
5624
|
+
}
|
|
5625
|
+
if (result.subdomainPolicy === "none" && result.policy !== "none") {
|
|
5626
|
+
result.warnings.push("Subdomain policy is less strict than domain policy");
|
|
5627
|
+
}
|
|
5628
|
+
if (!result.reportingEnabled) {
|
|
5629
|
+
result.warnings.push("No aggregate reporting configured (rua=)");
|
|
5630
|
+
}
|
|
5631
|
+
for (const addr of [...result.ruaAddresses, ...result.rufAddresses]) {
|
|
5632
|
+
const match = addr.match(/^mailto:([^@]+)@(.+)$/i);
|
|
5633
|
+
if (match) {
|
|
5634
|
+
const domain = result.record?.includes("_dmarc.") ? result.record.split("_dmarc.")[1]?.split(" ")[0] : null;
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
result.valid = result.errors.length === 0;
|
|
5638
|
+
}
|
|
5639
|
+
function parseDmarcTags(record) {
|
|
5640
|
+
const tags = /* @__PURE__ */ new Map();
|
|
5641
|
+
const parts = record.split(";").map((p) => p.trim()).filter(Boolean);
|
|
5642
|
+
for (const part of parts) {
|
|
5643
|
+
const eqIndex = part.indexOf("=");
|
|
5644
|
+
if (eqIndex === -1) continue;
|
|
5645
|
+
const key = part.slice(0, eqIndex).trim().toLowerCase();
|
|
5646
|
+
const value = part.slice(eqIndex + 1).trim();
|
|
5647
|
+
tags.set(key, value);
|
|
5648
|
+
}
|
|
5649
|
+
return tags;
|
|
5650
|
+
}
|
|
5651
|
+
function parseReportAddresses(value) {
|
|
5652
|
+
return value.split(",").map((addr) => addr.trim()).filter(Boolean);
|
|
5653
|
+
}
|
|
5654
|
+
async function checkMx(domain) {
|
|
5655
|
+
const result = {
|
|
5656
|
+
exists: false,
|
|
5657
|
+
records: [],
|
|
5658
|
+
hasRedundancy: false,
|
|
5659
|
+
warnings: []
|
|
5660
|
+
};
|
|
5661
|
+
try {
|
|
5662
|
+
const mxRecords = await resolveMxRecords(domain);
|
|
5663
|
+
if (mxRecords.length === 0) {
|
|
5664
|
+
result.warnings.push("No MX records found");
|
|
5665
|
+
return result;
|
|
5666
|
+
}
|
|
5667
|
+
result.exists = true;
|
|
5668
|
+
for (const mx of mxRecords) {
|
|
5669
|
+
const mxResult = await checkMxRecord(mx);
|
|
5670
|
+
result.records.push(mxResult);
|
|
5671
|
+
}
|
|
5672
|
+
const uniquePriorities = new Set(mxRecords.map((r) => r.priority));
|
|
5673
|
+
result.hasRedundancy = mxRecords.length > 1 && uniquePriorities.size > 1;
|
|
5674
|
+
const unresolving = result.records.filter((r) => !r.resolves);
|
|
5675
|
+
if (unresolving.length > 0) {
|
|
5676
|
+
result.warnings.push(`${unresolving.length} MX record(s) do not resolve`);
|
|
5677
|
+
}
|
|
5678
|
+
const ipMx = result.records.filter((r) => r.isIpAddress);
|
|
5679
|
+
if (ipMx.length > 0) {
|
|
5680
|
+
result.warnings.push(
|
|
5681
|
+
"MX records should point to hostnames, not IP addresses"
|
|
5682
|
+
);
|
|
5683
|
+
}
|
|
5684
|
+
const localhostMx = result.records.filter((r) => r.isLocalhost);
|
|
5685
|
+
if (localhostMx.length > 0) {
|
|
5686
|
+
result.warnings.push("MX record points to localhost");
|
|
5687
|
+
}
|
|
5688
|
+
} catch (error) {
|
|
5689
|
+
result.warnings.push(`Error checking MX: ${error.message}`);
|
|
5690
|
+
}
|
|
5691
|
+
return result;
|
|
5692
|
+
}
|
|
5693
|
+
async function checkMxRecord(mx) {
|
|
5694
|
+
const result = {
|
|
5695
|
+
priority: mx.priority,
|
|
5696
|
+
exchange: mx.exchange,
|
|
5697
|
+
resolves: false,
|
|
5698
|
+
ipv4Addresses: [],
|
|
5699
|
+
ipv6Addresses: [],
|
|
5700
|
+
isLocalhost: false,
|
|
5701
|
+
isIpAddress: isIpAddress(mx.exchange),
|
|
5702
|
+
reverseHostnames: []
|
|
5703
|
+
};
|
|
5704
|
+
if (result.isIpAddress) {
|
|
5705
|
+
result.isLocalhost = isLocalhost(mx.exchange);
|
|
5706
|
+
result.resolves = true;
|
|
5707
|
+
if (mx.exchange.includes(":")) {
|
|
5708
|
+
result.ipv6Addresses = [mx.exchange];
|
|
5709
|
+
} else {
|
|
5710
|
+
result.ipv4Addresses = [mx.exchange];
|
|
5711
|
+
}
|
|
5712
|
+
return result;
|
|
5713
|
+
}
|
|
5714
|
+
try {
|
|
5715
|
+
const ipv4 = await resolveARecords(mx.exchange);
|
|
5716
|
+
result.ipv4Addresses = ipv4;
|
|
5717
|
+
const ipv6 = await resolveAaaaRecords(mx.exchange);
|
|
5718
|
+
result.ipv6Addresses = ipv6;
|
|
5719
|
+
result.resolves = ipv4.length > 0 || ipv6.length > 0;
|
|
5720
|
+
for (const ip of [...ipv4, ...ipv6]) {
|
|
5721
|
+
if (isLocalhost(ip)) {
|
|
5722
|
+
result.isLocalhost = true;
|
|
5723
|
+
break;
|
|
5724
|
+
}
|
|
5725
|
+
}
|
|
5726
|
+
const allIps = [...ipv4, ...ipv6];
|
|
5727
|
+
for (const ip of allIps.slice(0, 3)) {
|
|
5728
|
+
try {
|
|
5729
|
+
const ptrs = await resolvePtrRecords(ip);
|
|
5730
|
+
result.reverseHostnames.push(...ptrs);
|
|
5731
|
+
} catch {
|
|
5732
|
+
}
|
|
5733
|
+
}
|
|
5734
|
+
} catch (error) {
|
|
5735
|
+
}
|
|
5736
|
+
return result;
|
|
5737
|
+
}
|
|
5738
|
+
var DEFAULT_TIMEOUT22 = 1e4;
|
|
5739
|
+
var SMTP_PORT = 25;
|
|
5740
|
+
async function checkMxTls(mxRecords, options = {}) {
|
|
5741
|
+
const { skip = false, timeout = DEFAULT_TIMEOUT22, quick = false } = options;
|
|
5742
|
+
const result = {
|
|
5743
|
+
checked: false,
|
|
5744
|
+
skipped: skip,
|
|
5745
|
+
skipReason: skip ? "--skip-tls flag" : null,
|
|
5746
|
+
servers: []
|
|
5747
|
+
};
|
|
5748
|
+
if (skip) {
|
|
5749
|
+
return result;
|
|
5750
|
+
}
|
|
5751
|
+
if (mxRecords.length === 0) {
|
|
5752
|
+
result.skipReason = "No MX records";
|
|
5753
|
+
result.skipped = true;
|
|
5754
|
+
return result;
|
|
5755
|
+
}
|
|
5756
|
+
const sortedMx = [...mxRecords].sort((a, b) => a.priority - b.priority);
|
|
5757
|
+
const mxToCheck = quick ? sortedMx.slice(0, 1) : sortedMx.slice(0, 5);
|
|
5758
|
+
const checkPromises = mxToCheck.map(
|
|
5759
|
+
(mx) => checkServer(mx.exchange, timeout)
|
|
5760
|
+
);
|
|
5761
|
+
try {
|
|
5762
|
+
result.servers = await Promise.all(checkPromises);
|
|
5763
|
+
result.checked = true;
|
|
5764
|
+
} catch (error) {
|
|
5765
|
+
result.skipReason = error.message || "TLS check failed";
|
|
5766
|
+
}
|
|
5767
|
+
return result;
|
|
5768
|
+
}
|
|
5769
|
+
async function checkServer(hostname, timeout) {
|
|
5770
|
+
const result = {
|
|
5771
|
+
server: hostname,
|
|
5772
|
+
port: SMTP_PORT,
|
|
5773
|
+
connected: false,
|
|
5774
|
+
connectionError: null,
|
|
5775
|
+
supportsStarttls: false,
|
|
5776
|
+
tlsVersions: [],
|
|
5777
|
+
preferredTlsVersion: null,
|
|
5778
|
+
cipherSuite: null,
|
|
5779
|
+
certificate: null,
|
|
5780
|
+
errors: []
|
|
5781
|
+
};
|
|
5782
|
+
try {
|
|
5783
|
+
const smtpSession = await connectSmtp(hostname, SMTP_PORT, timeout);
|
|
5784
|
+
result.connected = true;
|
|
5785
|
+
const capabilities = await sendEhlo(smtpSession, timeout);
|
|
5786
|
+
result.supportsStarttls = capabilities.includes("STARTTLS");
|
|
5787
|
+
if (!result.supportsStarttls) {
|
|
5788
|
+
smtpSession.socket.destroy();
|
|
5789
|
+
result.errors.push("Server does not advertise STARTTLS");
|
|
5790
|
+
return result;
|
|
5791
|
+
}
|
|
5792
|
+
const tlsResult = await upgradeToTls(smtpSession, hostname, timeout);
|
|
5793
|
+
if (tlsResult.success && tlsResult.socket) {
|
|
5794
|
+
result.preferredTlsVersion = tlsResult.tlsVersion || null;
|
|
5795
|
+
result.cipherSuite = tlsResult.cipher || null;
|
|
5796
|
+
if (tlsResult.tlsVersion) {
|
|
5797
|
+
result.tlsVersions.push(tlsResult.tlsVersion);
|
|
5798
|
+
}
|
|
5799
|
+
if (tlsResult.certificate) {
|
|
5800
|
+
const cert = tlsResult.certificate;
|
|
5801
|
+
const now = /* @__PURE__ */ new Date();
|
|
5802
|
+
const expiresAt = new Date(cert.valid_to);
|
|
5803
|
+
const daysUntilExpiry = Math.floor(
|
|
5804
|
+
(expiresAt.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24)
|
|
5805
|
+
);
|
|
5806
|
+
result.certificate = {
|
|
5807
|
+
valid: tlsResult.authorized ?? false,
|
|
5808
|
+
issuer: formatX509Name(cert.issuer),
|
|
5809
|
+
subject: formatX509Name(cert.subject),
|
|
5810
|
+
altNames: parseAltNames(cert.subjectaltname),
|
|
5811
|
+
expiresAt: cert.valid_to,
|
|
5812
|
+
daysUntilExpiry,
|
|
5813
|
+
matchesHostname: tlsResult.authorized ?? false,
|
|
5814
|
+
selfSigned: isSelfSigned(cert),
|
|
5815
|
+
chainValid: tlsResult.authorized ?? false
|
|
5816
|
+
};
|
|
5817
|
+
}
|
|
5818
|
+
tlsResult.socket.destroy();
|
|
5819
|
+
} else {
|
|
5820
|
+
result.errors.push(tlsResult.error || "TLS upgrade failed");
|
|
5821
|
+
}
|
|
5822
|
+
} catch (error) {
|
|
5823
|
+
result.connectionError = error.message || "Connection failed";
|
|
5824
|
+
if (error.code === "ECONNREFUSED") {
|
|
5825
|
+
result.connectionError = "Connection refused (port 25 blocked?)";
|
|
5826
|
+
} else if (error.code === "ETIMEDOUT" || error.message?.includes("timeout")) {
|
|
5827
|
+
result.connectionError = "Connection timed out";
|
|
5828
|
+
} else if (error.code === "ENOTFOUND") {
|
|
5829
|
+
result.connectionError = "Host not found";
|
|
5830
|
+
}
|
|
5831
|
+
}
|
|
5832
|
+
return result;
|
|
5833
|
+
}
|
|
5834
|
+
function connectSmtp(hostname, port, timeout) {
|
|
5835
|
+
return new Promise((resolve, reject) => {
|
|
5836
|
+
const socket = net.createConnection({ host: hostname, port });
|
|
5837
|
+
let buffer = "";
|
|
5838
|
+
let resolved = false;
|
|
5839
|
+
const timer = setTimeout(() => {
|
|
5840
|
+
if (!resolved) {
|
|
5841
|
+
resolved = true;
|
|
5842
|
+
socket.destroy();
|
|
5843
|
+
reject(new Error("Connection timeout"));
|
|
5844
|
+
}
|
|
5845
|
+
}, timeout);
|
|
5846
|
+
socket.on("connect", () => {
|
|
5847
|
+
});
|
|
5848
|
+
socket.on("data", (data) => {
|
|
5849
|
+
buffer += data.toString();
|
|
5850
|
+
if (buffer.includes("\r\n") && buffer.startsWith("220") && !resolved) {
|
|
5851
|
+
resolved = true;
|
|
5852
|
+
clearTimeout(timer);
|
|
5853
|
+
resolve({ socket, buffer: "" });
|
|
5854
|
+
}
|
|
5855
|
+
});
|
|
5856
|
+
socket.on("error", (err) => {
|
|
5857
|
+
if (!resolved) {
|
|
5858
|
+
resolved = true;
|
|
5859
|
+
clearTimeout(timer);
|
|
5860
|
+
reject(err);
|
|
5861
|
+
}
|
|
5862
|
+
});
|
|
5863
|
+
socket.on("close", () => {
|
|
5864
|
+
if (!resolved) {
|
|
5865
|
+
resolved = true;
|
|
5866
|
+
clearTimeout(timer);
|
|
5867
|
+
reject(new Error("Connection closed"));
|
|
5868
|
+
}
|
|
5869
|
+
});
|
|
5870
|
+
});
|
|
5871
|
+
}
|
|
5872
|
+
function sendEhlo(session, timeout) {
|
|
5873
|
+
return new Promise((resolve, reject) => {
|
|
5874
|
+
let buffer = "";
|
|
5875
|
+
let resolved = false;
|
|
5876
|
+
const timer = setTimeout(() => {
|
|
5877
|
+
if (!resolved) {
|
|
5878
|
+
resolved = true;
|
|
5879
|
+
reject(new Error("EHLO timeout"));
|
|
5880
|
+
}
|
|
5881
|
+
}, timeout);
|
|
5882
|
+
session.socket.on("data", (data) => {
|
|
5883
|
+
buffer += data.toString();
|
|
5884
|
+
const lines = buffer.split("\r\n").filter(Boolean);
|
|
5885
|
+
const lastLine = lines[lines.length - 1];
|
|
5886
|
+
if (lastLine && /^250[ ]/.test(lastLine) && !resolved) {
|
|
5887
|
+
resolved = true;
|
|
5888
|
+
clearTimeout(timer);
|
|
5889
|
+
const capabilities = lines.filter((line) => line.startsWith("250")).map((line) => line.slice(4).trim().toUpperCase());
|
|
5890
|
+
resolve(capabilities);
|
|
5891
|
+
}
|
|
5892
|
+
});
|
|
5893
|
+
session.socket.on("error", (err) => {
|
|
5894
|
+
if (!resolved) {
|
|
5895
|
+
resolved = true;
|
|
5896
|
+
clearTimeout(timer);
|
|
5897
|
+
reject(err);
|
|
5898
|
+
}
|
|
5899
|
+
});
|
|
5900
|
+
session.socket.write("EHLO mail.check.wraps.dev\r\n");
|
|
5901
|
+
});
|
|
5902
|
+
}
|
|
5903
|
+
function upgradeToTls(session, hostname, timeout) {
|
|
5904
|
+
return new Promise((resolve) => {
|
|
5905
|
+
let buffer = "";
|
|
5906
|
+
let resolved = false;
|
|
5907
|
+
const timer = setTimeout(() => {
|
|
5908
|
+
if (!resolved) {
|
|
5909
|
+
resolved = true;
|
|
5910
|
+
resolve({ success: false, error: "STARTTLS timeout" });
|
|
5911
|
+
}
|
|
5912
|
+
}, timeout);
|
|
5913
|
+
const onData = (data) => {
|
|
5914
|
+
buffer += data.toString();
|
|
5915
|
+
if (buffer.includes("220")) {
|
|
5916
|
+
session.socket.removeListener("data", onData);
|
|
5917
|
+
const tlsSocket = tls.connect(
|
|
5918
|
+
{
|
|
5919
|
+
socket: session.socket,
|
|
5920
|
+
servername: hostname,
|
|
5921
|
+
rejectUnauthorized: false,
|
|
5922
|
+
// We'll check manually
|
|
5923
|
+
minVersion: "TLSv1.2"
|
|
5924
|
+
},
|
|
5925
|
+
() => {
|
|
5926
|
+
if (!resolved) {
|
|
5927
|
+
resolved = true;
|
|
5928
|
+
clearTimeout(timer);
|
|
5929
|
+
const cipher = tlsSocket.getCipher();
|
|
5930
|
+
const cert = tlsSocket.getPeerCertificate();
|
|
5931
|
+
resolve({
|
|
5932
|
+
success: true,
|
|
5933
|
+
socket: tlsSocket,
|
|
5934
|
+
tlsVersion: tlsSocket.getProtocol() || void 0,
|
|
5935
|
+
cipher: cipher?.name,
|
|
5936
|
+
certificate: cert,
|
|
5937
|
+
authorized: tlsSocket.authorized
|
|
5938
|
+
});
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5941
|
+
);
|
|
5942
|
+
tlsSocket.on("error", (err) => {
|
|
5943
|
+
if (!resolved) {
|
|
5944
|
+
resolved = true;
|
|
5945
|
+
clearTimeout(timer);
|
|
5946
|
+
resolve({ success: false, error: err.message });
|
|
5947
|
+
}
|
|
5948
|
+
});
|
|
5949
|
+
} else if (buffer.includes("454") || buffer.includes("501")) {
|
|
5950
|
+
if (!resolved) {
|
|
5951
|
+
resolved = true;
|
|
5952
|
+
clearTimeout(timer);
|
|
5953
|
+
resolve({ success: false, error: "Server rejected STARTTLS" });
|
|
5954
|
+
}
|
|
5955
|
+
}
|
|
5956
|
+
};
|
|
5957
|
+
session.socket.on("data", onData);
|
|
5958
|
+
session.socket.on("error", (err) => {
|
|
5959
|
+
if (!resolved) {
|
|
5960
|
+
resolved = true;
|
|
5961
|
+
clearTimeout(timer);
|
|
5962
|
+
resolve({ success: false, error: err.message });
|
|
5963
|
+
}
|
|
5964
|
+
});
|
|
5965
|
+
session.socket.write("STARTTLS\r\n");
|
|
5966
|
+
});
|
|
5967
|
+
}
|
|
5968
|
+
function formatX509Name(name) {
|
|
5969
|
+
if (!name) return "";
|
|
5970
|
+
const parts = [];
|
|
5971
|
+
if (name.O) parts.push(name.O);
|
|
5972
|
+
if (name.CN) parts.push(name.CN);
|
|
5973
|
+
return parts.join(" - ") || JSON.stringify(name);
|
|
5974
|
+
}
|
|
5975
|
+
function parseAltNames(subjectaltname) {
|
|
5976
|
+
if (!subjectaltname) return [];
|
|
5977
|
+
return subjectaltname.split(", ").filter((san) => san.startsWith("DNS:")).map((san) => san.slice(4));
|
|
5978
|
+
}
|
|
5979
|
+
function isSelfSigned(cert) {
|
|
5980
|
+
if (!(cert.issuer && cert.subject)) return false;
|
|
5981
|
+
return cert.issuer.CN === cert.subject.CN && cert.issuer.O === cert.subject.O;
|
|
5982
|
+
}
|
|
5983
|
+
var RDAP_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json";
|
|
5984
|
+
var rdapBootstrapCache = null;
|
|
5985
|
+
var bootstrapCacheTime = 0;
|
|
5986
|
+
var BOOTSTRAP_CACHE_TTL = 36e5;
|
|
5987
|
+
async function checkDomainAge(domain, options = {}) {
|
|
5988
|
+
const { quick = false, timeout = 1e4 } = options;
|
|
5989
|
+
const result = {
|
|
5990
|
+
createdAt: null,
|
|
5991
|
+
expiresAt: null,
|
|
5992
|
+
updatedAt: null,
|
|
5993
|
+
ageInDays: null,
|
|
5994
|
+
daysUntilExpiry: null,
|
|
5995
|
+
registrar: null,
|
|
5996
|
+
registrantOrganization: null,
|
|
5997
|
+
registrantCountry: null,
|
|
5998
|
+
nameservers: [],
|
|
5999
|
+
dnssecEnabled: false,
|
|
6000
|
+
source: "unavailable",
|
|
6001
|
+
privacyEnabled: false,
|
|
6002
|
+
errors: []
|
|
6003
|
+
};
|
|
6004
|
+
if (quick) {
|
|
6005
|
+
result.errors.push("Skipped in quick mode");
|
|
6006
|
+
return result;
|
|
6007
|
+
}
|
|
6008
|
+
try {
|
|
6009
|
+
const tld = getTld(domain);
|
|
6010
|
+
if (!tld) {
|
|
6011
|
+
result.errors.push("Could not determine TLD");
|
|
6012
|
+
return result;
|
|
6013
|
+
}
|
|
6014
|
+
const rdapServer = await findRdapServer(tld, timeout);
|
|
6015
|
+
if (!rdapServer) {
|
|
6016
|
+
result.errors.push(`No RDAP server found for .${tld}`);
|
|
6017
|
+
return result;
|
|
6018
|
+
}
|
|
6019
|
+
const rdapData = await queryRdap(rdapServer, domain, timeout);
|
|
6020
|
+
if (!rdapData) {
|
|
6021
|
+
result.errors.push("RDAP query returned no data");
|
|
6022
|
+
return result;
|
|
6023
|
+
}
|
|
6024
|
+
parseRdapResponse(rdapData, result);
|
|
6025
|
+
result.source = "rdap";
|
|
6026
|
+
} catch (error) {
|
|
6027
|
+
result.errors.push(error.message || "RDAP lookup failed");
|
|
6028
|
+
}
|
|
6029
|
+
return result;
|
|
6030
|
+
}
|
|
6031
|
+
function getTld(domain) {
|
|
6032
|
+
const parts = domain.toLowerCase().split(".");
|
|
6033
|
+
if (parts.length < 2) return null;
|
|
6034
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
6035
|
+
const secondLevelTlds = [
|
|
6036
|
+
"co.uk",
|
|
6037
|
+
"org.uk",
|
|
6038
|
+
"me.uk",
|
|
6039
|
+
"ac.uk",
|
|
6040
|
+
"com.au",
|
|
6041
|
+
"net.au",
|
|
6042
|
+
"org.au",
|
|
6043
|
+
"co.nz",
|
|
6044
|
+
"org.nz",
|
|
6045
|
+
"co.jp",
|
|
6046
|
+
"or.jp",
|
|
6047
|
+
"com.br",
|
|
6048
|
+
"org.br"
|
|
6049
|
+
];
|
|
6050
|
+
if (secondLevelTlds.includes(lastTwo) && parts.length > 2) {
|
|
6051
|
+
return lastTwo;
|
|
6052
|
+
}
|
|
6053
|
+
return parts[parts.length - 1] || null;
|
|
6054
|
+
}
|
|
6055
|
+
async function findRdapServer(tld, timeout) {
|
|
6056
|
+
const now = Date.now();
|
|
6057
|
+
if (rdapBootstrapCache && now - bootstrapCacheTime < BOOTSTRAP_CACHE_TTL) {
|
|
6058
|
+
return rdapBootstrapCache.get(tld.toLowerCase()) || null;
|
|
6059
|
+
}
|
|
6060
|
+
try {
|
|
6061
|
+
const controller = new AbortController();
|
|
6062
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
6063
|
+
const response = await fetch(RDAP_BOOTSTRAP_URL, {
|
|
6064
|
+
signal: controller.signal,
|
|
6065
|
+
headers: { Accept: "application/json" }
|
|
6066
|
+
});
|
|
6067
|
+
clearTimeout(timeoutId);
|
|
6068
|
+
if (!response.ok) {
|
|
6069
|
+
return null;
|
|
6070
|
+
}
|
|
6071
|
+
const bootstrap = await response.json();
|
|
6072
|
+
rdapBootstrapCache = /* @__PURE__ */ new Map();
|
|
6073
|
+
bootstrapCacheTime = now;
|
|
6074
|
+
for (const [tlds, urls] of bootstrap.services) {
|
|
6075
|
+
const serverUrl = urls[0];
|
|
6076
|
+
if (serverUrl) {
|
|
6077
|
+
for (const t of tlds) {
|
|
6078
|
+
rdapBootstrapCache.set(t.toLowerCase(), serverUrl);
|
|
6079
|
+
}
|
|
6080
|
+
}
|
|
6081
|
+
}
|
|
6082
|
+
return rdapBootstrapCache.get(tld.toLowerCase()) || null;
|
|
6083
|
+
} catch {
|
|
6084
|
+
return null;
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
async function queryRdap(serverUrl, domain, timeout) {
|
|
6088
|
+
try {
|
|
6089
|
+
let url = serverUrl;
|
|
6090
|
+
if (!url.endsWith("/")) url += "/";
|
|
6091
|
+
url += `domain/${domain}`;
|
|
6092
|
+
const controller = new AbortController();
|
|
6093
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
6094
|
+
const response = await fetch(url, {
|
|
6095
|
+
signal: controller.signal,
|
|
6096
|
+
headers: { Accept: "application/rdap+json, application/json" }
|
|
6097
|
+
});
|
|
6098
|
+
clearTimeout(timeoutId);
|
|
6099
|
+
if (!response.ok) {
|
|
6100
|
+
return null;
|
|
6101
|
+
}
|
|
6102
|
+
return await response.json();
|
|
6103
|
+
} catch {
|
|
6104
|
+
return null;
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
6107
|
+
function parseRdapResponse(data, result) {
|
|
6108
|
+
const now = /* @__PURE__ */ new Date();
|
|
6109
|
+
if (data.events) {
|
|
6110
|
+
for (const event of data.events) {
|
|
6111
|
+
const date = event.eventDate;
|
|
6112
|
+
switch (event.eventAction) {
|
|
6113
|
+
case "registration":
|
|
6114
|
+
result.createdAt = date;
|
|
6115
|
+
break;
|
|
6116
|
+
case "expiration":
|
|
6117
|
+
result.expiresAt = date;
|
|
6118
|
+
break;
|
|
6119
|
+
case "last changed":
|
|
6120
|
+
case "last update of RDAP database":
|
|
6121
|
+
if (!result.updatedAt) {
|
|
6122
|
+
result.updatedAt = date;
|
|
6123
|
+
}
|
|
6124
|
+
break;
|
|
6125
|
+
}
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
6128
|
+
if (result.createdAt) {
|
|
6129
|
+
const created = new Date(result.createdAt);
|
|
6130
|
+
result.ageInDays = Math.floor(
|
|
6131
|
+
(now.getTime() - created.getTime()) / (1e3 * 60 * 60 * 24)
|
|
6132
|
+
);
|
|
6133
|
+
}
|
|
6134
|
+
if (result.expiresAt) {
|
|
6135
|
+
const expires = new Date(result.expiresAt);
|
|
6136
|
+
result.daysUntilExpiry = Math.floor(
|
|
6137
|
+
(expires.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24)
|
|
6138
|
+
);
|
|
6139
|
+
}
|
|
6140
|
+
if (data.nameservers) {
|
|
6141
|
+
result.nameservers = data.nameservers.map((ns) => ns.ldhName?.toLowerCase()).filter((ns) => !!ns);
|
|
6142
|
+
}
|
|
6143
|
+
if (data.secureDNS?.delegationSigned) {
|
|
6144
|
+
result.dnssecEnabled = true;
|
|
6145
|
+
}
|
|
6146
|
+
if (data.entities) {
|
|
6147
|
+
for (const entity of data.entities) {
|
|
6148
|
+
const roles = entity.roles || [];
|
|
6149
|
+
if (roles.includes("registrar")) {
|
|
6150
|
+
const name = extractVcardName(entity.vcardArray);
|
|
6151
|
+
if (name) result.registrar = name;
|
|
6152
|
+
}
|
|
6153
|
+
if (roles.includes("registrant")) {
|
|
6154
|
+
const name = extractVcardName(entity.vcardArray);
|
|
6155
|
+
if (name) {
|
|
6156
|
+
result.registrantOrganization = name;
|
|
6157
|
+
const privacyIndicators = [
|
|
6158
|
+
"privacy",
|
|
6159
|
+
"proxy",
|
|
6160
|
+
"whoisguard",
|
|
6161
|
+
"domains by proxy",
|
|
6162
|
+
"contact privacy",
|
|
6163
|
+
"redacted",
|
|
6164
|
+
"withheld"
|
|
6165
|
+
];
|
|
6166
|
+
const nameLower = name.toLowerCase();
|
|
6167
|
+
result.privacyEnabled = privacyIndicators.some(
|
|
6168
|
+
(p) => nameLower.includes(p)
|
|
6169
|
+
);
|
|
6170
|
+
}
|
|
6171
|
+
const country = extractVcardCountry(entity.vcardArray);
|
|
6172
|
+
if (country) result.registrantCountry = country;
|
|
6173
|
+
}
|
|
6174
|
+
}
|
|
6175
|
+
}
|
|
6176
|
+
}
|
|
6177
|
+
function extractVcardName(vcardArray) {
|
|
6178
|
+
if (!vcardArray || vcardArray[0] !== "vcard") return null;
|
|
6179
|
+
const properties = vcardArray[1];
|
|
6180
|
+
if (!Array.isArray(properties)) return null;
|
|
6181
|
+
for (const prop of properties) {
|
|
6182
|
+
if (!Array.isArray(prop)) continue;
|
|
6183
|
+
const [name, , , value] = prop;
|
|
6184
|
+
if (name === "fn" && typeof value === "string" && value.trim()) {
|
|
6185
|
+
return value.trim();
|
|
6186
|
+
}
|
|
6187
|
+
if (name === "org" && typeof value === "string" && value.trim()) {
|
|
6188
|
+
return value.trim();
|
|
6189
|
+
}
|
|
6190
|
+
}
|
|
6191
|
+
return null;
|
|
6192
|
+
}
|
|
6193
|
+
function extractVcardCountry(vcardArray) {
|
|
6194
|
+
if (!vcardArray || vcardArray[0] !== "vcard") return null;
|
|
6195
|
+
const properties = vcardArray[1];
|
|
6196
|
+
if (!Array.isArray(properties)) return null;
|
|
6197
|
+
for (const prop of properties) {
|
|
6198
|
+
if (!Array.isArray(prop)) continue;
|
|
6199
|
+
const [name, , , value] = prop;
|
|
6200
|
+
if (name === "adr" && Array.isArray(value)) {
|
|
6201
|
+
const country = value[6];
|
|
6202
|
+
if (typeof country === "string" && country.trim()) {
|
|
6203
|
+
return country.trim();
|
|
6204
|
+
}
|
|
6205
|
+
}
|
|
6206
|
+
}
|
|
6207
|
+
return null;
|
|
6208
|
+
}
|
|
6209
|
+
async function checkSpf(domain) {
|
|
6210
|
+
const spfRecords = await findSpfRecord(domain);
|
|
6211
|
+
const result = {
|
|
6212
|
+
exists: spfRecords.length > 0,
|
|
6213
|
+
record: spfRecords[0] || null,
|
|
6214
|
+
records: spfRecords,
|
|
6215
|
+
multipleRecords: spfRecords.length > 1,
|
|
6216
|
+
valid: false,
|
|
6217
|
+
syntaxErrors: [],
|
|
6218
|
+
warnings: [],
|
|
6219
|
+
lookupCount: 0,
|
|
6220
|
+
lookupLimit: SPF_LOOKUP_LIMIT,
|
|
6221
|
+
lookupTree: [],
|
|
6222
|
+
allMechanism: null,
|
|
6223
|
+
includes: [],
|
|
6224
|
+
hasPtr: false,
|
|
6225
|
+
hasDuplicates: false,
|
|
6226
|
+
hasCircularInclude: false,
|
|
6227
|
+
recordLength: spfRecords[0]?.length || 0,
|
|
6228
|
+
usesMacros: false,
|
|
6229
|
+
macros: []
|
|
6230
|
+
};
|
|
6231
|
+
if (spfRecords.length > 1) {
|
|
6232
|
+
result.syntaxErrors.push(
|
|
6233
|
+
`Multiple SPF records found (${spfRecords.length}). RFC 7208 allows only one.`
|
|
6234
|
+
);
|
|
6235
|
+
return result;
|
|
6236
|
+
}
|
|
6237
|
+
if (!(result.exists && result.record)) {
|
|
6238
|
+
return result;
|
|
6239
|
+
}
|
|
6240
|
+
try {
|
|
6241
|
+
await parseAndValidateSpf(domain, result.record, result);
|
|
6242
|
+
} catch (error) {
|
|
6243
|
+
result.syntaxErrors.push(error.message);
|
|
6244
|
+
}
|
|
6245
|
+
return result;
|
|
6246
|
+
}
|
|
6247
|
+
async function parseAndValidateSpf(domain, record, result) {
|
|
6248
|
+
if (!record.startsWith("v=spf1")) {
|
|
6249
|
+
result.syntaxErrors.push('SPF record must start with "v=spf1"');
|
|
6250
|
+
return;
|
|
6251
|
+
}
|
|
6252
|
+
const mechanisms = record.slice(6).trim().split(/\s+/).filter(Boolean);
|
|
6253
|
+
const context = {
|
|
6254
|
+
visited: /* @__PURE__ */ new Set([domain]),
|
|
6255
|
+
totalLookups: 0,
|
|
6256
|
+
hasCircular: false,
|
|
6257
|
+
errors: []
|
|
6258
|
+
};
|
|
6259
|
+
const includes = [];
|
|
6260
|
+
let allMechanism = null;
|
|
6261
|
+
let hasPtr = false;
|
|
6262
|
+
for (const mechanism of mechanisms) {
|
|
6263
|
+
const parsed = parseMechanism(mechanism);
|
|
6264
|
+
if (!parsed) {
|
|
6265
|
+
if (mechanism.includes("%{")) {
|
|
6266
|
+
result.usesMacros = true;
|
|
6267
|
+
result.macros.push(mechanism);
|
|
6268
|
+
result.warnings.push(
|
|
6269
|
+
`SPF uses macro "${mechanism}" - cannot fully validate without sending IP`
|
|
6270
|
+
);
|
|
6271
|
+
} else if (mechanism !== "") {
|
|
6272
|
+
result.syntaxErrors.push(`Invalid mechanism: ${mechanism}`);
|
|
6273
|
+
}
|
|
6274
|
+
continue;
|
|
6275
|
+
}
|
|
6276
|
+
switch (parsed.type) {
|
|
6277
|
+
case "all":
|
|
6278
|
+
allMechanism = parsed.qualifier;
|
|
6279
|
+
break;
|
|
6280
|
+
case "include":
|
|
6281
|
+
if (parsed.domain) {
|
|
6282
|
+
includes.push(parsed.domain);
|
|
6283
|
+
const includeNode = await checkSpfInclude(
|
|
6284
|
+
parsed.domain,
|
|
6285
|
+
context,
|
|
6286
|
+
mechanism
|
|
6287
|
+
);
|
|
6288
|
+
result.lookupTree.push(includeNode);
|
|
6289
|
+
}
|
|
6290
|
+
break;
|
|
6291
|
+
case "redirect":
|
|
6292
|
+
if (parsed.domain) {
|
|
6293
|
+
const redirectNode = await checkSpfInclude(
|
|
6294
|
+
parsed.domain,
|
|
6295
|
+
context,
|
|
6296
|
+
mechanism
|
|
6297
|
+
);
|
|
6298
|
+
result.lookupTree.push(redirectNode);
|
|
6299
|
+
}
|
|
6300
|
+
break;
|
|
6301
|
+
case "a":
|
|
6302
|
+
context.totalLookups++;
|
|
6303
|
+
if (parsed.domain) {
|
|
6304
|
+
const aNode = {
|
|
6305
|
+
mechanism,
|
|
6306
|
+
type: "a",
|
|
6307
|
+
domain: parsed.domain,
|
|
6308
|
+
lookups: 1,
|
|
6309
|
+
children: [],
|
|
6310
|
+
error: null
|
|
6311
|
+
};
|
|
6312
|
+
result.lookupTree.push(aNode);
|
|
6313
|
+
}
|
|
6314
|
+
break;
|
|
6315
|
+
case "mx":
|
|
6316
|
+
context.totalLookups++;
|
|
6317
|
+
if (parsed.domain) {
|
|
6318
|
+
const mxNode = {
|
|
6319
|
+
mechanism,
|
|
6320
|
+
type: "mx",
|
|
6321
|
+
domain: parsed.domain,
|
|
6322
|
+
lookups: 1,
|
|
6323
|
+
children: [],
|
|
6324
|
+
error: null
|
|
6325
|
+
};
|
|
6326
|
+
result.lookupTree.push(mxNode);
|
|
6327
|
+
}
|
|
6328
|
+
break;
|
|
6329
|
+
case "ptr":
|
|
6330
|
+
hasPtr = true;
|
|
6331
|
+
context.totalLookups++;
|
|
6332
|
+
result.warnings.push("SPF uses deprecated 'ptr' mechanism (RFC 7208)");
|
|
6333
|
+
break;
|
|
6334
|
+
case "exists":
|
|
6335
|
+
context.totalLookups++;
|
|
6336
|
+
break;
|
|
6337
|
+
case "ip4":
|
|
6338
|
+
case "ip6":
|
|
6339
|
+
break;
|
|
6340
|
+
default:
|
|
6341
|
+
result.warnings.push(`Unknown mechanism type: ${parsed.type}`);
|
|
6342
|
+
}
|
|
6343
|
+
}
|
|
6344
|
+
const seenIncludes = /* @__PURE__ */ new Set();
|
|
6345
|
+
for (const inc of includes) {
|
|
6346
|
+
if (seenIncludes.has(inc)) {
|
|
6347
|
+
result.hasDuplicates = true;
|
|
6348
|
+
result.warnings.push(`Duplicate include: ${inc}`);
|
|
6349
|
+
}
|
|
6350
|
+
seenIncludes.add(inc);
|
|
6351
|
+
}
|
|
6352
|
+
result.includes = includes;
|
|
6353
|
+
result.allMechanism = allMechanism;
|
|
6354
|
+
result.hasPtr = hasPtr;
|
|
6355
|
+
result.lookupCount = context.totalLookups;
|
|
6356
|
+
result.hasCircularInclude = context.hasCircular;
|
|
6357
|
+
result.valid = result.syntaxErrors.length === 0 && !result.multipleRecords && !result.hasCircularInclude;
|
|
6358
|
+
if (context.totalLookups > SPF_LOOKUP_LIMIT) {
|
|
6359
|
+
result.warnings.push(
|
|
6360
|
+
`SPF exceeds 10 DNS lookup limit (${context.totalLookups} lookups)`
|
|
6361
|
+
);
|
|
6362
|
+
} else if (context.totalLookups === SPF_LOOKUP_LIMIT) {
|
|
6363
|
+
result.warnings.push(
|
|
6364
|
+
"SPF at lookup limit (10). Adding more includes will fail."
|
|
6365
|
+
);
|
|
6366
|
+
}
|
|
6367
|
+
if (allMechanism === "+all") {
|
|
6368
|
+
result.syntaxErrors.push("SPF ends with +all which allows anyone to send");
|
|
6369
|
+
result.valid = false;
|
|
6370
|
+
} else if (allMechanism === "?all") {
|
|
6371
|
+
result.warnings.push(
|
|
6372
|
+
"SPF uses ?all (neutral) - consider using -all or ~all"
|
|
6373
|
+
);
|
|
6374
|
+
} else if (!allMechanism) {
|
|
6375
|
+
result.warnings.push(
|
|
6376
|
+
"SPF record has no 'all' mechanism - defaults to neutral"
|
|
6377
|
+
);
|
|
6378
|
+
}
|
|
6379
|
+
if (context.errors.length > 0) {
|
|
6380
|
+
result.warnings.push(...context.errors);
|
|
6381
|
+
}
|
|
6382
|
+
}
|
|
6383
|
+
async function checkSpfInclude(domain, context, mechanism) {
|
|
6384
|
+
const node = {
|
|
6385
|
+
mechanism,
|
|
6386
|
+
type: mechanism.startsWith("redirect=") ? "redirect" : "include",
|
|
6387
|
+
domain,
|
|
6388
|
+
lookups: 1,
|
|
6389
|
+
children: [],
|
|
6390
|
+
error: null
|
|
6391
|
+
};
|
|
6392
|
+
if (context.visited.has(domain)) {
|
|
6393
|
+
context.hasCircular = true;
|
|
6394
|
+
node.error = `Circular include detected: ${domain}`;
|
|
6395
|
+
return node;
|
|
6396
|
+
}
|
|
6397
|
+
context.visited.add(domain);
|
|
6398
|
+
context.totalLookups++;
|
|
6399
|
+
try {
|
|
6400
|
+
const spfRecords = await findSpfRecord(domain);
|
|
6401
|
+
if (spfRecords.length === 0) {
|
|
6402
|
+
node.error = `No SPF record found for ${domain}`;
|
|
6403
|
+
return node;
|
|
6404
|
+
}
|
|
6405
|
+
if (spfRecords.length > 1) {
|
|
6406
|
+
node.error = `Multiple SPF records found for ${domain}`;
|
|
6407
|
+
return node;
|
|
6408
|
+
}
|
|
6409
|
+
const record = spfRecords[0];
|
|
6410
|
+
if (!record.startsWith("v=spf1")) {
|
|
6411
|
+
node.error = `Invalid SPF record for ${domain}`;
|
|
6412
|
+
return node;
|
|
6413
|
+
}
|
|
6414
|
+
const mechanisms = record.slice(6).trim().split(/\s+/).filter(Boolean);
|
|
6415
|
+
for (const mech of mechanisms) {
|
|
6416
|
+
const parsed = parseMechanism(mech);
|
|
6417
|
+
if (!parsed) continue;
|
|
6418
|
+
switch (parsed.type) {
|
|
6419
|
+
case "include":
|
|
6420
|
+
case "redirect":
|
|
6421
|
+
if (parsed.domain) {
|
|
6422
|
+
const childNode = await checkSpfInclude(
|
|
6423
|
+
parsed.domain,
|
|
6424
|
+
context,
|
|
6425
|
+
mech
|
|
6426
|
+
);
|
|
6427
|
+
node.children.push(childNode);
|
|
6428
|
+
node.lookups += childNode.lookups;
|
|
6429
|
+
}
|
|
6430
|
+
break;
|
|
6431
|
+
case "a":
|
|
6432
|
+
case "mx":
|
|
6433
|
+
case "ptr":
|
|
6434
|
+
case "exists":
|
|
6435
|
+
context.totalLookups++;
|
|
6436
|
+
node.lookups++;
|
|
6437
|
+
break;
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
} catch (error) {
|
|
6441
|
+
node.error = `Failed to resolve ${domain}: ${error.message}`;
|
|
6442
|
+
}
|
|
6443
|
+
return node;
|
|
6444
|
+
}
|
|
6445
|
+
function parseMechanism(mechanism) {
|
|
6446
|
+
if (!mechanism) return null;
|
|
6447
|
+
let qualifier = "+";
|
|
6448
|
+
let mech = mechanism;
|
|
6449
|
+
if (/^[+\-~?]/.test(mech)) {
|
|
6450
|
+
qualifier = mech.charAt(0);
|
|
6451
|
+
mech = mech.slice(1);
|
|
6452
|
+
}
|
|
6453
|
+
const match = mech.match(
|
|
6454
|
+
/^(all|include|a|mx|ptr|ip4|ip6|exists|redirect|exp)(?:[:=]([^\s/]+))?(?:\/(\d+))?$/i
|
|
6455
|
+
);
|
|
6456
|
+
if (!match) {
|
|
6457
|
+
return null;
|
|
6458
|
+
}
|
|
6459
|
+
const type = match[1];
|
|
6460
|
+
const value = match[2];
|
|
6461
|
+
const prefix = match[3];
|
|
6462
|
+
if (!type) {
|
|
6463
|
+
return null;
|
|
6464
|
+
}
|
|
6465
|
+
return {
|
|
6466
|
+
qualifier,
|
|
6467
|
+
type: type.toLowerCase(),
|
|
6468
|
+
domain: value,
|
|
6469
|
+
prefix
|
|
6470
|
+
};
|
|
6471
|
+
}
|
|
6472
|
+
function formatSpfLookupTree(nodes, indent = "") {
|
|
6473
|
+
const lines = [];
|
|
6474
|
+
for (const [i, node] of nodes.entries()) {
|
|
6475
|
+
const isLast = i === nodes.length - 1;
|
|
6476
|
+
const prefix = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
6477
|
+
const childIndent = isLast ? " " : "\u2502 ";
|
|
6478
|
+
let line = `${indent}${prefix}${node.mechanism}`;
|
|
6479
|
+
if (node.lookups > 1) {
|
|
6480
|
+
line += ` (${node.lookups} lookups)`;
|
|
6481
|
+
} else {
|
|
6482
|
+
line += " (1)";
|
|
6483
|
+
}
|
|
6484
|
+
if (node.error) {
|
|
6485
|
+
line += ` \u26A0 ${node.error}`;
|
|
6486
|
+
}
|
|
6487
|
+
lines.push(line);
|
|
6488
|
+
if (node.children.length > 0) {
|
|
6489
|
+
lines.push(formatSpfLookupTree(node.children, indent + childIndent));
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
return lines.join("\n");
|
|
6493
|
+
}
|
|
6494
|
+
function calculateScore(checks) {
|
|
6495
|
+
let score = 100;
|
|
6496
|
+
const deductions = [];
|
|
6497
|
+
const bonuses = [];
|
|
6498
|
+
if (!checks.spf.exists) {
|
|
6499
|
+
score -= 30;
|
|
6500
|
+
deductions.push({ check: "spf", points: 30, reason: "No SPF record" });
|
|
6501
|
+
} else if (checks.spf.multipleRecords) {
|
|
6502
|
+
score -= 30;
|
|
6503
|
+
deductions.push({
|
|
6504
|
+
check: "spf",
|
|
6505
|
+
points: 30,
|
|
6506
|
+
reason: "Multiple SPF records (RFC violation)"
|
|
6507
|
+
});
|
|
6508
|
+
} else if (!checks.spf.valid) {
|
|
6509
|
+
score -= 30;
|
|
6510
|
+
deductions.push({ check: "spf", points: 30, reason: "Invalid SPF syntax" });
|
|
6511
|
+
} else if (checks.spf.allMechanism === "+all") {
|
|
6512
|
+
score -= 30;
|
|
6513
|
+
deductions.push({
|
|
6514
|
+
check: "spf",
|
|
6515
|
+
points: 30,
|
|
6516
|
+
reason: "SPF +all allows anyone"
|
|
6517
|
+
});
|
|
6518
|
+
} else if (checks.spf.hasCircularInclude) {
|
|
6519
|
+
score -= 30;
|
|
6520
|
+
deductions.push({
|
|
6521
|
+
check: "spf",
|
|
6522
|
+
points: 30,
|
|
6523
|
+
reason: "SPF has circular include (infinite loop)"
|
|
6524
|
+
});
|
|
6525
|
+
} else {
|
|
6526
|
+
if (checks.spf.allMechanism === "?all") {
|
|
6527
|
+
score -= 15;
|
|
6528
|
+
deductions.push({
|
|
6529
|
+
check: "spf",
|
|
6530
|
+
points: 15,
|
|
6531
|
+
reason: "SPF ?all is too permissive"
|
|
6532
|
+
});
|
|
6533
|
+
} else if (checks.spf.allMechanism === "~all") {
|
|
6534
|
+
score -= 5;
|
|
6535
|
+
deductions.push({
|
|
6536
|
+
check: "spf",
|
|
6537
|
+
points: 5,
|
|
6538
|
+
reason: "SPF ~all (softfail) instead of -all"
|
|
6539
|
+
});
|
|
6540
|
+
}
|
|
6541
|
+
if (checks.spf.lookupCount > 10) {
|
|
6542
|
+
score -= 10;
|
|
6543
|
+
deductions.push({
|
|
6544
|
+
check: "spf",
|
|
6545
|
+
points: 10,
|
|
6546
|
+
reason: `SPF exceeds 10 lookups (${checks.spf.lookupCount})`
|
|
6547
|
+
});
|
|
6548
|
+
}
|
|
6549
|
+
if (checks.spf.hasPtr) {
|
|
6550
|
+
score -= 2;
|
|
6551
|
+
deductions.push({
|
|
6552
|
+
check: "spf",
|
|
6553
|
+
points: 2,
|
|
6554
|
+
reason: "SPF uses deprecated ptr mechanism"
|
|
6555
|
+
});
|
|
6556
|
+
}
|
|
6557
|
+
}
|
|
6558
|
+
if (checks.dkim.found) {
|
|
6559
|
+
const validSelector = checks.dkim.selectors.find(
|
|
6560
|
+
(s) => s.valid && !s.revoked
|
|
6561
|
+
);
|
|
6562
|
+
if (validSelector) {
|
|
6563
|
+
if (validSelector.keyType === "rsa" && validSelector.keyBits && validSelector.keyBits < 1024) {
|
|
6564
|
+
score -= 20;
|
|
6565
|
+
deductions.push({
|
|
6566
|
+
check: "dkim",
|
|
6567
|
+
points: 20,
|
|
6568
|
+
reason: `DKIM key too weak (${validSelector.keyBits} bits)`
|
|
6569
|
+
});
|
|
6570
|
+
} else if (validSelector.keyType === "rsa" && validSelector.keyBits && validSelector.keyBits < 2048) {
|
|
6571
|
+
score -= 5;
|
|
6572
|
+
deductions.push({
|
|
6573
|
+
check: "dkim",
|
|
6574
|
+
points: 5,
|
|
6575
|
+
reason: `DKIM key should be 2048+ bits (${validSelector.keyBits})`
|
|
6576
|
+
});
|
|
6577
|
+
}
|
|
6578
|
+
if (validSelector.testMode) {
|
|
6579
|
+
score -= 10;
|
|
6580
|
+
deductions.push({
|
|
6581
|
+
check: "dkim",
|
|
6582
|
+
points: 10,
|
|
6583
|
+
reason: "DKIM in testing mode (t=y)"
|
|
6584
|
+
});
|
|
6585
|
+
}
|
|
6586
|
+
const validCount = checks.dkim.selectors.filter(
|
|
6587
|
+
(s) => s.valid && !s.revoked
|
|
6588
|
+
).length;
|
|
6589
|
+
if (validCount > 1) {
|
|
6590
|
+
bonuses.push({
|
|
6591
|
+
check: "dkim",
|
|
6592
|
+
points: 2,
|
|
6593
|
+
reason: `${validCount} valid DKIM selectors`
|
|
6594
|
+
});
|
|
6595
|
+
}
|
|
6596
|
+
} else {
|
|
6597
|
+
score -= 25;
|
|
6598
|
+
deductions.push({
|
|
6599
|
+
check: "dkim",
|
|
6600
|
+
points: 25,
|
|
6601
|
+
reason: "No valid DKIM record"
|
|
6602
|
+
});
|
|
6603
|
+
}
|
|
6604
|
+
} else {
|
|
6605
|
+
score -= 25;
|
|
6606
|
+
deductions.push({
|
|
6607
|
+
check: "dkim",
|
|
6608
|
+
points: 25,
|
|
6609
|
+
reason: "No DKIM record found"
|
|
6610
|
+
});
|
|
6611
|
+
}
|
|
6612
|
+
if (!checks.dmarc.exists) {
|
|
6613
|
+
score -= 20;
|
|
6614
|
+
deductions.push({ check: "dmarc", points: 20, reason: "No DMARC record" });
|
|
6615
|
+
} else if (checks.dmarc.valid) {
|
|
6616
|
+
if (checks.dmarc.policy === "none") {
|
|
6617
|
+
score -= 10;
|
|
6618
|
+
deductions.push({
|
|
6619
|
+
check: "dmarc",
|
|
6620
|
+
points: 10,
|
|
6621
|
+
reason: "DMARC policy is none (not enforcing)"
|
|
6622
|
+
});
|
|
6623
|
+
}
|
|
6624
|
+
if (checks.dmarc.percentage < 100) {
|
|
6625
|
+
score -= 5;
|
|
6626
|
+
deductions.push({
|
|
6627
|
+
check: "dmarc",
|
|
6628
|
+
points: 5,
|
|
6629
|
+
reason: `DMARC pct=${checks.dmarc.percentage} (not 100%)`
|
|
6630
|
+
});
|
|
6631
|
+
}
|
|
6632
|
+
if (!checks.dmarc.reportingEnabled) {
|
|
6633
|
+
score -= 5;
|
|
6634
|
+
deductions.push({
|
|
6635
|
+
check: "dmarc",
|
|
6636
|
+
points: 5,
|
|
6637
|
+
reason: "DMARC reporting not configured (no rua=)"
|
|
6638
|
+
});
|
|
6639
|
+
}
|
|
6640
|
+
if (checks.dmarc.alignmentSpf === "strict" && checks.dmarc.alignmentDkim === "strict") {
|
|
6641
|
+
bonuses.push({
|
|
6642
|
+
check: "dmarc",
|
|
6643
|
+
points: 2,
|
|
6644
|
+
reason: "DMARC strict alignment configured"
|
|
6645
|
+
});
|
|
6646
|
+
}
|
|
6647
|
+
} else {
|
|
6648
|
+
score -= 20;
|
|
6649
|
+
deductions.push({
|
|
6650
|
+
check: "dmarc",
|
|
6651
|
+
points: 20,
|
|
6652
|
+
reason: "Invalid DMARC syntax"
|
|
6653
|
+
});
|
|
6654
|
+
}
|
|
6655
|
+
if (checks.mx.exists) {
|
|
6656
|
+
const unresolving = checks.mx.records.filter((r) => !r.resolves);
|
|
6657
|
+
if (unresolving.length > 0) {
|
|
6658
|
+
score -= 5;
|
|
6659
|
+
deductions.push({
|
|
6660
|
+
check: "mx",
|
|
6661
|
+
points: 5,
|
|
6662
|
+
reason: `${unresolving.length} MX records don't resolve`
|
|
6663
|
+
});
|
|
6664
|
+
}
|
|
6665
|
+
if (checks.mx.hasRedundancy) {
|
|
6666
|
+
bonuses.push({
|
|
6667
|
+
check: "mx",
|
|
6668
|
+
points: 1,
|
|
6669
|
+
reason: "Multiple MX records for redundancy"
|
|
6670
|
+
});
|
|
6671
|
+
}
|
|
6672
|
+
} else {
|
|
6673
|
+
score -= 5;
|
|
6674
|
+
deductions.push({ check: "mx", points: 5, reason: "No MX records" });
|
|
6675
|
+
}
|
|
6676
|
+
const domainListings = checks.blacklist.domainChecks.listed;
|
|
6677
|
+
const ipListings = checks.blacklist.ipChecks.listed;
|
|
6678
|
+
for (const listing of [...domainListings, ...ipListings]) {
|
|
6679
|
+
if (listing.zone.includes("spamhaus")) {
|
|
6680
|
+
score -= 30;
|
|
6681
|
+
deductions.push({
|
|
6682
|
+
check: "blacklist",
|
|
6683
|
+
points: 30,
|
|
6684
|
+
reason: `Listed on ${listing.blacklist}`
|
|
6685
|
+
});
|
|
6686
|
+
} else if (listing.priority === "critical" || listing.priority === "high") {
|
|
6687
|
+
const pts = listing.priority === "critical" ? 15 : 10;
|
|
6688
|
+
score -= pts;
|
|
6689
|
+
deductions.push({
|
|
6690
|
+
check: "blacklist",
|
|
6691
|
+
points: pts,
|
|
6692
|
+
reason: `Listed on ${listing.blacklist}`
|
|
6693
|
+
});
|
|
6694
|
+
} else if (listing.priority === "medium") {
|
|
6695
|
+
score -= 5;
|
|
6696
|
+
deductions.push({
|
|
6697
|
+
check: "blacklist",
|
|
6698
|
+
points: 5,
|
|
6699
|
+
reason: `Listed on ${listing.blacklist}`
|
|
6700
|
+
});
|
|
6701
|
+
} else {
|
|
6702
|
+
score -= 2;
|
|
6703
|
+
deductions.push({
|
|
6704
|
+
check: "blacklist",
|
|
6705
|
+
points: 2,
|
|
6706
|
+
reason: `Listed on ${listing.blacklist}`
|
|
6707
|
+
});
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
if (checks.blacklist.overallClean) {
|
|
6711
|
+
bonuses.push({
|
|
6712
|
+
check: "blacklist",
|
|
6713
|
+
points: 5,
|
|
6714
|
+
reason: "Clean on all blacklists"
|
|
6715
|
+
});
|
|
6716
|
+
}
|
|
6717
|
+
if (checks.mtaSts.configured && checks.mtaSts.policy?.mode === "enforce") {
|
|
6718
|
+
bonuses.push({ check: "mta-sts", points: 5, reason: "MTA-STS enforcing" });
|
|
6719
|
+
} else if (checks.mtaSts.configured && checks.mtaSts.policy?.mode === "testing") {
|
|
6720
|
+
bonuses.push({
|
|
6721
|
+
check: "mta-sts",
|
|
6722
|
+
points: 2,
|
|
6723
|
+
reason: "MTA-STS testing mode"
|
|
6724
|
+
});
|
|
6725
|
+
}
|
|
6726
|
+
if (checks.tlsRpt.configured) {
|
|
6727
|
+
bonuses.push({ check: "tls-rpt", points: 2, reason: "TLS-RPT configured" });
|
|
6728
|
+
}
|
|
6729
|
+
if (checks.bimi.configured && checks.bimi.dmarcCompatible) {
|
|
6730
|
+
if (checks.bimi.vmcValid) {
|
|
6731
|
+
bonuses.push({ check: "bimi", points: 5, reason: "BIMI with VMC" });
|
|
6732
|
+
} else if (checks.bimi.logoAccessible) {
|
|
6733
|
+
bonuses.push({
|
|
6734
|
+
check: "bimi",
|
|
6735
|
+
points: 2,
|
|
6736
|
+
reason: "BIMI configured (no VMC)"
|
|
6737
|
+
});
|
|
6738
|
+
}
|
|
6739
|
+
}
|
|
6740
|
+
if (checks.mxTls.checked && !checks.mxTls.skipped) {
|
|
6741
|
+
const allMxTls13 = checks.mxTls.servers.every(
|
|
6742
|
+
(s) => s.tlsVersions?.includes("TLSv1.3")
|
|
6743
|
+
);
|
|
6744
|
+
if (allMxTls13) {
|
|
6745
|
+
bonuses.push({
|
|
6746
|
+
check: "mx-tls",
|
|
6747
|
+
points: 2,
|
|
6748
|
+
reason: "All MX servers support TLS 1.3"
|
|
6749
|
+
});
|
|
6750
|
+
}
|
|
6751
|
+
}
|
|
6752
|
+
if (checks.dnssec.enabled && checks.dnssec.valid) {
|
|
6753
|
+
bonuses.push({
|
|
6754
|
+
check: "dnssec",
|
|
6755
|
+
points: 3,
|
|
6756
|
+
reason: "DNSSEC enabled and valid"
|
|
6757
|
+
});
|
|
6758
|
+
} else if (checks.dnssec.enabled && !checks.dnssec.valid) {
|
|
6759
|
+
score -= 5;
|
|
6760
|
+
deductions.push({ check: "dnssec", points: 5, reason: "DNSSEC broken" });
|
|
6761
|
+
}
|
|
6762
|
+
if (checks.ipv6.mxHasIpv6 && checks.ipv6.spfIncludesIpv6) {
|
|
6763
|
+
bonuses.push({ check: "ipv6", points: 2, reason: "Full IPv6 support" });
|
|
6764
|
+
} else if (checks.ipv6.mxHasIpv6) {
|
|
6765
|
+
bonuses.push({ check: "ipv6", points: 1, reason: "Partial IPv6 support" });
|
|
6766
|
+
}
|
|
6767
|
+
if (checks.reverseDns.allHavePtr && checks.reverseDns.allConfirm) {
|
|
6768
|
+
bonuses.push({ check: "ptr", points: 2, reason: "All PTR records valid" });
|
|
6769
|
+
} else if (!checks.reverseDns.allHavePtr) {
|
|
6770
|
+
score -= 5;
|
|
6771
|
+
deductions.push({ check: "ptr", points: 5, reason: "Missing reverse DNS" });
|
|
6772
|
+
}
|
|
6773
|
+
if (checks.domainAge.ageInDays !== null) {
|
|
6774
|
+
if (checks.domainAge.ageInDays < 30) {
|
|
6775
|
+
score -= 10;
|
|
6776
|
+
deductions.push({
|
|
6777
|
+
check: "domain-age",
|
|
6778
|
+
points: 10,
|
|
6779
|
+
reason: "Domain less than 30 days old"
|
|
6780
|
+
});
|
|
6781
|
+
} else if (checks.domainAge.ageInDays < 90) {
|
|
6782
|
+
score -= 5;
|
|
6783
|
+
deductions.push({
|
|
6784
|
+
check: "domain-age",
|
|
6785
|
+
points: 5,
|
|
6786
|
+
reason: "Domain less than 90 days old"
|
|
6787
|
+
});
|
|
6788
|
+
} else if (checks.domainAge.ageInDays > 365) {
|
|
6789
|
+
bonuses.push({
|
|
6790
|
+
check: "domain-age",
|
|
6791
|
+
points: 2,
|
|
6792
|
+
reason: "Domain over 1 year old"
|
|
6793
|
+
});
|
|
6794
|
+
}
|
|
6795
|
+
}
|
|
6796
|
+
if (checks.caa.configured) {
|
|
6797
|
+
bonuses.push({ check: "caa", points: 1, reason: "CAA records configured" });
|
|
6798
|
+
}
|
|
6799
|
+
const totalBonus = bonuses.reduce((sum, b) => sum + b.points, 0);
|
|
6800
|
+
const rawScore = score + Math.min(totalBonus, 20);
|
|
6801
|
+
const finalScore = Math.min(100, Math.max(0, rawScore));
|
|
6802
|
+
return {
|
|
6803
|
+
rawScore,
|
|
6804
|
+
finalScore,
|
|
6805
|
+
grade: getGrade(finalScore),
|
|
6806
|
+
deductions,
|
|
6807
|
+
bonuses,
|
|
6808
|
+
breakdown: {
|
|
6809
|
+
spf: {
|
|
6810
|
+
max: 30,
|
|
6811
|
+
score: Math.max(0, 30 - sumDeductions(deductions, "spf"))
|
|
6812
|
+
},
|
|
6813
|
+
dkim: {
|
|
6814
|
+
max: 25,
|
|
6815
|
+
score: Math.max(0, 25 - sumDeductions(deductions, "dkim"))
|
|
6816
|
+
},
|
|
6817
|
+
dmarc: {
|
|
6818
|
+
max: 25,
|
|
6819
|
+
score: Math.max(0, 25 - sumDeductions(deductions, "dmarc"))
|
|
6820
|
+
},
|
|
6821
|
+
mx: { max: 10, score: Math.max(0, 10 - sumDeductions(deductions, "mx")) },
|
|
6822
|
+
blacklist: {
|
|
6823
|
+
max: 10,
|
|
6824
|
+
score: Math.max(0, 10 - sumDeductions(deductions, "blacklist"))
|
|
6825
|
+
},
|
|
6826
|
+
bonus: { earned: Math.min(totalBonus, 20), possible: 20 }
|
|
6827
|
+
}
|
|
6828
|
+
};
|
|
6829
|
+
}
|
|
6830
|
+
function getGrade(score) {
|
|
6831
|
+
if (score >= 90) return "A";
|
|
6832
|
+
if (score >= 80) return "B";
|
|
6833
|
+
if (score >= 70) return "C";
|
|
6834
|
+
if (score >= 50) return "D";
|
|
6835
|
+
return "F";
|
|
6836
|
+
}
|
|
6837
|
+
function sumDeductions(deductions, check2) {
|
|
6838
|
+
return deductions.filter((d) => d.check === check2).reduce((sum, d) => sum + d.points, 0);
|
|
6839
|
+
}
|
|
6840
|
+
async function runEmailCheck(domain, options = {}) {
|
|
6841
|
+
const startTime = Date.now();
|
|
6842
|
+
const normalizedDomain = toAsciiDomain(domain);
|
|
6843
|
+
if (!isValidDomain(normalizedDomain)) {
|
|
6844
|
+
throw new Error(`Invalid domain: ${domain}`);
|
|
6845
|
+
}
|
|
6846
|
+
const { quick = false, skipBlacklists = false, skipTls = false } = options;
|
|
6847
|
+
const [spfResult, dkimResult, dmarcResult, mxResult] = await Promise.all([
|
|
6848
|
+
checkSpf(normalizedDomain),
|
|
6849
|
+
checkDkim(normalizedDomain, {
|
|
6850
|
+
quick,
|
|
6851
|
+
selector: options.dkimSelector,
|
|
6852
|
+
selectors: options.dkimSelectors,
|
|
6853
|
+
verbose: options.verbose
|
|
6854
|
+
}),
|
|
6855
|
+
checkDmarc(normalizedDomain),
|
|
6856
|
+
checkMx(normalizedDomain)
|
|
6857
|
+
]);
|
|
6858
|
+
if (!dkimResult.found && spfResult.record) {
|
|
6859
|
+
const spfLower = spfResult.record.toLowerCase();
|
|
6860
|
+
if (spfLower.includes("amazonses") || spfLower.includes("_spf.aws")) {
|
|
6861
|
+
dkimResult.warnings.push(
|
|
6862
|
+
"AWS SES uses random DKIM selectors. Run `wraps email domains get-dkim -d <domain>` to find yours."
|
|
6863
|
+
);
|
|
6864
|
+
} else if (spfLower.includes("sendgrid")) {
|
|
6865
|
+
dkimResult.warnings.push(
|
|
6866
|
+
"SendGrid uses custom DKIM selectors. Check your SendGrid dashboard or use --dkimSelector."
|
|
6867
|
+
);
|
|
6868
|
+
} else if (spfLower.includes("mailgun")) {
|
|
6869
|
+
dkimResult.warnings.push(
|
|
6870
|
+
"Mailgun uses custom DKIM selectors. Check your Mailgun dashboard or use --dkimSelector."
|
|
6871
|
+
);
|
|
6872
|
+
}
|
|
6873
|
+
}
|
|
6874
|
+
const mxIps = [];
|
|
6875
|
+
for (const mx of mxResult.records) {
|
|
6876
|
+
mxIps.push(...mx.ipv4Addresses);
|
|
6877
|
+
}
|
|
6878
|
+
const blacklistResult = await checkBlacklist({
|
|
6879
|
+
domain: normalizedDomain,
|
|
6880
|
+
ips: mxIps,
|
|
6881
|
+
quick,
|
|
6882
|
+
skip: skipBlacklists
|
|
6883
|
+
});
|
|
6884
|
+
const mxTlsResult = await checkMxTls(mxResult.records, {
|
|
6885
|
+
skip: skipTls,
|
|
6886
|
+
quick
|
|
6887
|
+
});
|
|
6888
|
+
const mtaStsResult = {
|
|
6889
|
+
configured: false,
|
|
6890
|
+
dnsRecord: null,
|
|
6891
|
+
dnsRecordId: null,
|
|
6892
|
+
policyFetched: false,
|
|
6893
|
+
policyUrl: `https://mta-sts.${normalizedDomain}/.well-known/mta-sts.txt`,
|
|
6894
|
+
policy: null,
|
|
6895
|
+
mxPatternsMatch: false,
|
|
6896
|
+
errors: [],
|
|
6897
|
+
warnings: []
|
|
6898
|
+
};
|
|
6899
|
+
const tlsRptResult = {
|
|
6900
|
+
configured: false,
|
|
6901
|
+
record: null,
|
|
6902
|
+
version: null,
|
|
6903
|
+
reportingUris: [],
|
|
6904
|
+
errors: []
|
|
6905
|
+
};
|
|
6906
|
+
const reverseDnsResult = {
|
|
6907
|
+
results: [],
|
|
6908
|
+
allHavePtr: false,
|
|
6909
|
+
allConfirm: false,
|
|
6910
|
+
warnings: []
|
|
6911
|
+
};
|
|
6912
|
+
for (const mx of mxResult.records) {
|
|
6913
|
+
for (const ip of mx.ipv4Addresses) {
|
|
6914
|
+
const hasPtr = mx.reverseHostnames.length > 0;
|
|
6915
|
+
reverseDnsResult.results.push({
|
|
6916
|
+
ip,
|
|
6917
|
+
ipVersion: 4,
|
|
6918
|
+
ptrHostname: mx.reverseHostnames[0] || null,
|
|
6919
|
+
forwardConfirms: hasPtr,
|
|
6920
|
+
// Simplified - would need actual check
|
|
6921
|
+
looksGeneric: false,
|
|
6922
|
+
matchesDomain: mx.reverseHostnames.some(
|
|
6923
|
+
(h) => h.includes(normalizedDomain)
|
|
6924
|
+
)
|
|
6925
|
+
});
|
|
6926
|
+
}
|
|
6927
|
+
}
|
|
6928
|
+
reverseDnsResult.allHavePtr = reverseDnsResult.results.every(
|
|
6929
|
+
(r) => r.ptrHostname !== null
|
|
6930
|
+
);
|
|
6931
|
+
reverseDnsResult.allConfirm = reverseDnsResult.results.every(
|
|
6932
|
+
(r) => r.forwardConfirms
|
|
6933
|
+
);
|
|
6934
|
+
const ipv6Result = {
|
|
6935
|
+
mxHasIpv6: mxResult.records.some((r) => r.ipv6Addresses.length > 0),
|
|
6936
|
+
mxIpv6Addresses: mxResult.records.filter((r) => r.ipv6Addresses.length > 0).map((r) => ({ mx: r.exchange, addresses: r.ipv6Addresses })),
|
|
6937
|
+
ipv6Connectable: false,
|
|
6938
|
+
// Would need actual check
|
|
6939
|
+
spfIncludesIpv6: spfResult.record?.includes("ip6:") ?? false,
|
|
6940
|
+
warnings: []
|
|
6941
|
+
};
|
|
6942
|
+
const domainAgeResult = await checkDomainAge(normalizedDomain, { quick });
|
|
6943
|
+
const dnssecResult = {
|
|
6944
|
+
enabled: false,
|
|
6945
|
+
valid: false,
|
|
6946
|
+
validationMethod: "system-resolver",
|
|
6947
|
+
chainOfTrust: [],
|
|
6948
|
+
algorithm: null,
|
|
6949
|
+
keyTag: null,
|
|
6950
|
+
errors: []
|
|
6951
|
+
};
|
|
6952
|
+
const caaResult = {
|
|
6953
|
+
configured: false,
|
|
6954
|
+
records: [],
|
|
6955
|
+
allowedIssuers: [],
|
|
6956
|
+
allowedWildcardIssuers: [],
|
|
6957
|
+
reportingConfigured: false,
|
|
6958
|
+
iodefUri: null
|
|
6959
|
+
};
|
|
6960
|
+
const bimiResult = {
|
|
6961
|
+
configured: false,
|
|
6962
|
+
record: null,
|
|
6963
|
+
logoUrl: null,
|
|
6964
|
+
vmcUrl: null,
|
|
6965
|
+
logoAccessible: false,
|
|
6966
|
+
logoValid: false,
|
|
6967
|
+
vmcAccessible: false,
|
|
6968
|
+
vmcValid: false,
|
|
6969
|
+
dmarcCompatible: dmarcResult.policy === "quarantine" || dmarcResult.policy === "reject",
|
|
6970
|
+
errors: [],
|
|
6971
|
+
warnings: []
|
|
6972
|
+
};
|
|
6973
|
+
const score = calculateScore({
|
|
6974
|
+
spf: spfResult,
|
|
6975
|
+
dkim: dkimResult,
|
|
6976
|
+
dmarc: dmarcResult,
|
|
6977
|
+
mx: mxResult,
|
|
6978
|
+
mxTls: mxTlsResult,
|
|
6979
|
+
mtaSts: mtaStsResult,
|
|
6980
|
+
tlsRpt: tlsRptResult,
|
|
6981
|
+
bimi: bimiResult,
|
|
6982
|
+
dnssec: dnssecResult,
|
|
6983
|
+
ipv6: ipv6Result,
|
|
6984
|
+
reverseDns: reverseDnsResult,
|
|
6985
|
+
blacklist: blacklistResult,
|
|
6986
|
+
domainAge: domainAgeResult,
|
|
6987
|
+
caa: caaResult
|
|
6988
|
+
});
|
|
6989
|
+
const duration = Date.now() - startTime;
|
|
6990
|
+
return {
|
|
6991
|
+
domain: normalizedDomain,
|
|
6992
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6993
|
+
duration,
|
|
6994
|
+
options,
|
|
6995
|
+
spf: spfResult,
|
|
6996
|
+
dkim: dkimResult,
|
|
6997
|
+
dmarc: dmarcResult,
|
|
6998
|
+
mx: mxResult,
|
|
6999
|
+
mxTls: mxTlsResult,
|
|
7000
|
+
mtaSts: mtaStsResult,
|
|
7001
|
+
tlsRpt: tlsRptResult,
|
|
7002
|
+
reverseDns: reverseDnsResult,
|
|
7003
|
+
ipv6: ipv6Result,
|
|
7004
|
+
blacklist: blacklistResult,
|
|
7005
|
+
domainAge: domainAgeResult,
|
|
7006
|
+
dnssec: dnssecResult,
|
|
7007
|
+
caa: caaResult,
|
|
7008
|
+
bimi: bimiResult,
|
|
7009
|
+
score
|
|
7010
|
+
};
|
|
7011
|
+
}
|
|
7012
|
+
function getExitCode(grade) {
|
|
7013
|
+
switch (grade) {
|
|
7014
|
+
case "A":
|
|
7015
|
+
case "B":
|
|
7016
|
+
return 0;
|
|
7017
|
+
case "C":
|
|
7018
|
+
case "D":
|
|
7019
|
+
return 1;
|
|
7020
|
+
case "F":
|
|
7021
|
+
return 2;
|
|
7022
|
+
default:
|
|
7023
|
+
return 4;
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
|
|
7027
|
+
// src/commands/email/check.ts
|
|
7028
|
+
init_events();
|
|
7029
|
+
import pc4 from "picocolors";
|
|
7030
|
+
async function check(options) {
|
|
7031
|
+
const startTime = Date.now();
|
|
7032
|
+
let domain = options.domain;
|
|
7033
|
+
if (!domain) {
|
|
7034
|
+
const input = await clack3.text({
|
|
7035
|
+
message: "Enter domain to check:",
|
|
7036
|
+
placeholder: "example.com",
|
|
7037
|
+
validate: (value) => {
|
|
7038
|
+
if (!value) return "Domain is required";
|
|
7039
|
+
if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(value)) {
|
|
7040
|
+
return "Invalid domain format";
|
|
7041
|
+
}
|
|
7042
|
+
}
|
|
7043
|
+
});
|
|
7044
|
+
if (clack3.isCancel(input)) {
|
|
7045
|
+
clack3.cancel("Operation cancelled");
|
|
7046
|
+
process.exit(0);
|
|
7047
|
+
}
|
|
7048
|
+
domain = input;
|
|
7049
|
+
}
|
|
7050
|
+
if (!options.json) {
|
|
7051
|
+
clack3.intro(pc4.bold("Wraps Email Check"));
|
|
7052
|
+
console.log();
|
|
7053
|
+
}
|
|
7054
|
+
const spinner4 = options.json ? null : clack3.spinner();
|
|
7055
|
+
spinner4?.start(`Checking ${pc4.cyan(domain)}...`);
|
|
7056
|
+
let dkimSelectors;
|
|
7057
|
+
if (!options.dkimSelector) {
|
|
7058
|
+
const sesTokens = await tryGetSesDkimTokens(domain);
|
|
7059
|
+
if (sesTokens.length > 0) {
|
|
7060
|
+
dkimSelectors = sesTokens;
|
|
7061
|
+
}
|
|
7062
|
+
}
|
|
7063
|
+
try {
|
|
7064
|
+
const result = await runEmailCheck(domain, {
|
|
7065
|
+
quick: options.quick,
|
|
7066
|
+
verbose: options.verbose,
|
|
7067
|
+
dkimSelector: options.dkimSelector,
|
|
7068
|
+
dkimSelectors,
|
|
7069
|
+
// Use SES tokens if found
|
|
7070
|
+
skipBlacklists: options.skipBlacklists,
|
|
7071
|
+
skipTls: options.skipTls,
|
|
7072
|
+
timeout: options.timeout
|
|
7073
|
+
});
|
|
7074
|
+
spinner4?.stop(`Check complete in ${result.duration}ms`);
|
|
7075
|
+
if (options.json) {
|
|
7076
|
+
console.log(JSON.stringify(result, null, 2));
|
|
7077
|
+
} else {
|
|
7078
|
+
displayResults(result, options);
|
|
7079
|
+
}
|
|
7080
|
+
const duration = Date.now() - startTime;
|
|
7081
|
+
trackCommand("email:check", {
|
|
7082
|
+
success: true,
|
|
7083
|
+
duration_ms: duration,
|
|
7084
|
+
grade: result.score.grade
|
|
7085
|
+
});
|
|
7086
|
+
process.exit(getExitCode(result.score.grade));
|
|
7087
|
+
} catch (error) {
|
|
7088
|
+
spinner4?.stop("Check failed");
|
|
7089
|
+
if (options.json) {
|
|
7090
|
+
console.log(JSON.stringify({ error: error.message }));
|
|
7091
|
+
} else {
|
|
7092
|
+
clack3.log.error(error.message);
|
|
7093
|
+
}
|
|
7094
|
+
const duration = Date.now() - startTime;
|
|
7095
|
+
trackCommand("email:check", {
|
|
7096
|
+
success: false,
|
|
7097
|
+
duration_ms: duration,
|
|
7098
|
+
error: error.message
|
|
7099
|
+
});
|
|
7100
|
+
process.exit(4);
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
7103
|
+
function displayResults(result, options) {
|
|
7104
|
+
const { score, spf, dkim, dmarc, mx, blacklist } = result;
|
|
7105
|
+
console.log();
|
|
7106
|
+
displayScoreBox(result.domain, score.finalScore, score.grade, options.quick);
|
|
7107
|
+
console.log();
|
|
7108
|
+
console.log(pc4.bold("AUTHENTICATION"));
|
|
7109
|
+
console.log();
|
|
7110
|
+
displaySpfResult(spf, options.verbose);
|
|
7111
|
+
displayDkimResult(dkim, options.verbose);
|
|
7112
|
+
displayDmarcResult(dmarc);
|
|
7113
|
+
console.log();
|
|
7114
|
+
console.log(pc4.bold("INFRASTRUCTURE"));
|
|
7115
|
+
console.log();
|
|
7116
|
+
displayMxResult(mx);
|
|
7117
|
+
displayMxTlsResult(result);
|
|
7118
|
+
displayReverseDnsResult(result);
|
|
7119
|
+
displayIpv6Result(result);
|
|
7120
|
+
console.log();
|
|
7121
|
+
console.log(pc4.bold("REPUTATION"));
|
|
7122
|
+
console.log();
|
|
7123
|
+
displayBlacklistResult(blacklist, options.quick);
|
|
7124
|
+
displayDomainAgeResult(result);
|
|
7125
|
+
if (result.dnssec.enabled || result.caa.configured || result.mtaSts.configured || result.tlsRpt.configured) {
|
|
7126
|
+
console.log();
|
|
7127
|
+
console.log(pc4.bold("SECURITY"));
|
|
7128
|
+
console.log();
|
|
7129
|
+
if (result.dnssec.enabled) {
|
|
7130
|
+
const status2 = result.dnssec.valid ? pc4.green("\u2713") : pc4.red("\u2717");
|
|
7131
|
+
console.log(
|
|
7132
|
+
` ${status2} ${pc4.dim("DNSSEC")} ${result.dnssec.valid ? "Enabled and validated" : "Broken"}`
|
|
7133
|
+
);
|
|
7134
|
+
} else {
|
|
7135
|
+
console.log(
|
|
7136
|
+
` ${pc4.dim("\u25CB")} ${pc4.dim("DNSSEC")} Not configured`
|
|
7137
|
+
);
|
|
7138
|
+
}
|
|
7139
|
+
if (result.caa.configured) {
|
|
7140
|
+
console.log(
|
|
7141
|
+
` ${pc4.green("\u2713")} ${pc4.dim("CAA")} Configured (${result.caa.allowedIssuers.join(", ")})`
|
|
7142
|
+
);
|
|
7143
|
+
} else {
|
|
7144
|
+
console.log(
|
|
7145
|
+
` ${pc4.dim("\u25CB")} ${pc4.dim("CAA")} Not configured`
|
|
7146
|
+
);
|
|
7147
|
+
}
|
|
7148
|
+
if (result.mtaSts.configured) {
|
|
7149
|
+
const mode = result.mtaSts.policy?.mode || "unknown";
|
|
7150
|
+
console.log(
|
|
7151
|
+
` ${pc4.green("\u2713")} ${pc4.dim("MTA-STS")} ${mode === "enforce" ? "Enforcing mode" : `${mode} mode`}`
|
|
7152
|
+
);
|
|
7153
|
+
} else {
|
|
7154
|
+
console.log(
|
|
7155
|
+
` ${pc4.dim("\u25CB")} ${pc4.dim("MTA-STS")} Not configured`
|
|
7156
|
+
);
|
|
7157
|
+
}
|
|
7158
|
+
if (result.tlsRpt.configured) {
|
|
7159
|
+
console.log(
|
|
7160
|
+
` ${pc4.green("\u2713")} ${pc4.dim("TLS-RPT")} Configured`
|
|
7161
|
+
);
|
|
7162
|
+
} else {
|
|
7163
|
+
console.log(
|
|
7164
|
+
` ${pc4.dim("\u25CB")} ${pc4.dim("TLS-RPT")} Not configured`
|
|
7165
|
+
);
|
|
7166
|
+
}
|
|
7167
|
+
}
|
|
7168
|
+
const criticalDeductions = score.deductions.filter((d) => d.points >= 20);
|
|
7169
|
+
const warningDeductions = score.deductions.filter(
|
|
7170
|
+
(d) => d.points >= 5 && d.points < 20
|
|
7171
|
+
);
|
|
7172
|
+
if (criticalDeductions.length > 0 || warningDeductions.length > 0) {
|
|
7173
|
+
console.log();
|
|
7174
|
+
console.log(pc4.dim("\u2500".repeat(78)));
|
|
7175
|
+
console.log();
|
|
7176
|
+
console.log(
|
|
7177
|
+
pc4.bold(
|
|
7178
|
+
`ISSUES (${criticalDeductions.length} critical, ${warningDeductions.length} warnings)`
|
|
7179
|
+
)
|
|
7180
|
+
);
|
|
7181
|
+
console.log();
|
|
7182
|
+
if (criticalDeductions.length > 0) {
|
|
7183
|
+
console.log(pc4.red("\u274C CRITICAL"));
|
|
7184
|
+
console.log();
|
|
7185
|
+
for (let i = 0; i < criticalDeductions.length; i++) {
|
|
7186
|
+
const d = criticalDeductions[i];
|
|
7187
|
+
console.log(
|
|
7188
|
+
` ${i + 1}. ${d.reason} (${pc4.red(`-${d.points} points`)})`
|
|
7189
|
+
);
|
|
7190
|
+
console.log();
|
|
7191
|
+
console.log(` ${getFixSuggestion(d.check, d.reason)}`);
|
|
7192
|
+
console.log();
|
|
7193
|
+
}
|
|
7194
|
+
}
|
|
7195
|
+
if (warningDeductions.length > 0) {
|
|
7196
|
+
console.log(pc4.yellow("\u26A0\uFE0F WARNINGS"));
|
|
7197
|
+
console.log();
|
|
7198
|
+
for (let i = 0; i < warningDeductions.length; i++) {
|
|
7199
|
+
const d = warningDeductions[i];
|
|
7200
|
+
console.log(` ${criticalDeductions.length + i + 1}. ${d.reason}`);
|
|
7201
|
+
console.log();
|
|
7202
|
+
}
|
|
7203
|
+
}
|
|
7204
|
+
}
|
|
7205
|
+
console.log();
|
|
7206
|
+
console.log(pc4.dim("\u2500".repeat(78)));
|
|
7207
|
+
console.log();
|
|
7208
|
+
if (score.grade === "A" || score.grade === "B") {
|
|
7209
|
+
console.log(
|
|
7210
|
+
pc4.green(
|
|
7211
|
+
"\u2705 " + (score.grade === "A" ? "Excellent! Your email configuration follows all best practices." : "Good! Your email configuration is solid with minor improvements possible.")
|
|
7212
|
+
)
|
|
7213
|
+
);
|
|
7214
|
+
} else {
|
|
7215
|
+
console.log(
|
|
7216
|
+
pc4.yellow(
|
|
7217
|
+
"Need help fixing these? Deploy Wraps to manage your email infrastructure:"
|
|
7218
|
+
)
|
|
7219
|
+
);
|
|
7220
|
+
console.log();
|
|
7221
|
+
console.log(` ${pc4.cyan("npx @wraps.dev/cli email init")}`);
|
|
7222
|
+
}
|
|
7223
|
+
console.log();
|
|
7224
|
+
console.log(`Share: ${pc4.cyan(`https://wraps.dev/check/${result.domain}`)}`);
|
|
7225
|
+
console.log();
|
|
7226
|
+
}
|
|
7227
|
+
function displayScoreBox(domain, score, grade, quick) {
|
|
7228
|
+
const width = 78;
|
|
7229
|
+
const bar = "\u2588".repeat(Math.round(score / 100 * 60));
|
|
7230
|
+
const emptyBar = "\u2591".repeat(60 - bar.length);
|
|
7231
|
+
const gradeColor = grade === "A" || grade === "B" ? pc4.green : grade === "C" ? pc4.yellow : grade === "D" ? pc4.magenta : pc4.red;
|
|
7232
|
+
console.log(pc4.dim("\u256D" + "\u2500".repeat(width - 2) + "\u256E"));
|
|
7233
|
+
console.log(pc4.dim("\u2502") + " ".repeat(width - 2) + pc4.dim("\u2502"));
|
|
7234
|
+
console.log(
|
|
7235
|
+
pc4.dim("\u2502") + " " + pc4.bold(`wraps email check${quick ? " --quick" : ""}`) + " ".repeat(width - 25 - (quick ? 8 : 0)) + pc4.dim("\u2502")
|
|
4819
7236
|
);
|
|
4820
7237
|
console.log(pc4.dim("\u2502") + " ".repeat(width - 2) + pc4.dim("\u2502"));
|
|
4821
7238
|
console.log(
|
|
@@ -6498,7 +8915,7 @@ async function scanAWSResources(region) {
|
|
|
6498
8915
|
}
|
|
6499
8916
|
|
|
6500
8917
|
// src/commands/email/connect.ts
|
|
6501
|
-
async function
|
|
8918
|
+
async function connect2(options) {
|
|
6502
8919
|
const startTime = Date.now();
|
|
6503
8920
|
clack6.intro(
|
|
6504
8921
|
pc7.bold(
|
|
@@ -7056,7 +9473,7 @@ Run ${pc8.cyan("wraps email init")} to deploy infrastructure again.
|
|
|
7056
9473
|
init_esm_shims();
|
|
7057
9474
|
init_events();
|
|
7058
9475
|
init_aws();
|
|
7059
|
-
import { Resolver } from "dns/promises";
|
|
9476
|
+
import { Resolver as Resolver2 } from "dns/promises";
|
|
7060
9477
|
import { GetEmailIdentityCommand as GetEmailIdentityCommand2, SESv2Client as SESv2Client3 } from "@aws-sdk/client-sesv2";
|
|
7061
9478
|
import * as clack8 from "@clack/prompts";
|
|
7062
9479
|
import pc9 from "picocolors";
|
|
@@ -7091,7 +9508,7 @@ Run ${pc9.cyan(`wraps email init --domain ${options.domain}`)} to add this domai
|
|
|
7091
9508
|
process.exit(1);
|
|
7092
9509
|
return;
|
|
7093
9510
|
}
|
|
7094
|
-
const resolver = new
|
|
9511
|
+
const resolver = new Resolver2();
|
|
7095
9512
|
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
7096
9513
|
const dnsResults = [];
|
|
7097
9514
|
for (const token of dkimTokens) {
|
|
@@ -10009,7 +12426,7 @@ function createMetricsRouter(config2) {
|
|
|
10009
12426
|
|
|
10010
12427
|
// src/console/routes/settings.ts
|
|
10011
12428
|
init_esm_shims();
|
|
10012
|
-
import
|
|
12429
|
+
import dns2 from "dns/promises";
|
|
10013
12430
|
import { Router as createRouter4 } from "express";
|
|
10014
12431
|
|
|
10015
12432
|
// src/console/services/settings-service.ts
|
|
@@ -10166,7 +12583,7 @@ function createSettingsRouter(config2) {
|
|
|
10166
12583
|
}
|
|
10167
12584
|
console.log(`[Verify] Checking CNAME for: ${domain}`);
|
|
10168
12585
|
console.log(`[Verify] Expected target: ${expectedTarget}`);
|
|
10169
|
-
const records = await
|
|
12586
|
+
const records = await dns2.resolveCname(domain);
|
|
10170
12587
|
console.log("[Verify] CNAME records found:", records);
|
|
10171
12588
|
const verified = records.some(
|
|
10172
12589
|
(record) => record.toLowerCase().includes(expectedTarget.toLowerCase())
|
|
@@ -10199,7 +12616,7 @@ function createSettingsRouter(config2) {
|
|
|
10199
12616
|
}
|
|
10200
12617
|
const dmarcDomain = `_dmarc.${domain}`;
|
|
10201
12618
|
console.log(`[Verify] Checking DMARC for: ${dmarcDomain}`);
|
|
10202
|
-
const records = await
|
|
12619
|
+
const records = await dns2.resolveTxt(dmarcDomain);
|
|
10203
12620
|
console.log("[Verify] TXT records found:", records);
|
|
10204
12621
|
const hasDmarc = records.some((record) => {
|
|
10205
12622
|
const value = record.join("");
|
|
@@ -15726,7 +18143,7 @@ if (!primaryCommand) {
|
|
|
15726
18143
|
preview: flags.preview
|
|
15727
18144
|
});
|
|
15728
18145
|
} else {
|
|
15729
|
-
await
|
|
18146
|
+
await connect2({
|
|
15730
18147
|
provider: flags.provider,
|
|
15731
18148
|
region: flags.region,
|
|
15732
18149
|
yes: flags.yes,
|
|
@@ -15785,7 +18202,7 @@ async function run() {
|
|
|
15785
18202
|
});
|
|
15786
18203
|
break;
|
|
15787
18204
|
case "connect":
|
|
15788
|
-
await
|
|
18205
|
+
await connect2({
|
|
15789
18206
|
provider: flags.provider,
|
|
15790
18207
|
region: flags.region,
|
|
15791
18208
|
yes: flags.yes,
|