Repository navigation
fix(sales): diagnose inbound-only mail path behind blocked sender trust #102
Changes from 9 commits
2b7879e
61dd573
36ee83a
f03b9a4
4894f85
a337028
2d7df77
69385bd
dde56c8
c86a291
ab14b71
1e746b8
5bff0d0
b6fc899
9955591
c7bf1f1
089761f
f9935d0
b1e1b42
4dcd78e
416601a
5e20697
ce8ff6b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| #!/usr/bin/env node | ||
| import { resolveTxt } from "node:dns/promises"; | ||
| import { resolveMx, resolveTxt } from "node:dns/promises"; | ||
| import { agencyConfig } from "./lib/agency-config.mjs"; | ||
|
|
||
| const strict = process.argv.includes("--strict"); | ||
|
|
@@ -47,6 +47,17 @@ async function txtRecords(domain) { | |
| } | ||
| } | ||
|
|
||
| async function mxRecords(domain) { | ||
| try { | ||
| const records = await resolveMx(domain); | ||
| return records | ||
| .sort((a, b) => a.priority - b.priority) | ||
| .map((record) => record.exchange.toLowerCase()); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| function warn(rule, detail) { | ||
| warnings.push({ rule, detail }); | ||
| } | ||
|
|
@@ -91,6 +102,21 @@ if (!domain) { | |
| checks.push({ name: "DMARC", status: dmarc ? "found" : "missing", domain: dmarcDomain }); | ||
| if (!dmarc) warn("missing DMARC", `No DMARC TXT record found for ${dmarcDomain}.`); | ||
|
|
||
| const mx = await mxRecords(domain); | ||
| if (mx.length) { | ||
| const inboundOnly = mx.every((host) => /^route\d+\.mx\.cloudflare\.net\.?$/.test(host)); | ||
| checks.push({ name: "Outbound mail path", status: inboundOnly ? "missing" : "found", domain: mx.join(", ") }); | ||
|
Comment on lines
+107
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
MX records describe where inbound mail is received, not how the domain sends mail. A valid setup using Cloudflare Email Routing for inbound mail and Resend/Postmark/SendGrid for outbound will continue to show this check as Useful? React with 👍 / 👎.
Comment on lines
+107
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a domain publishes a null MX record ( Useful? React with 👍 / 👎. |
||
| if (inboundOnly && !config.dkimSelector) { | ||
| warn( | ||
| "outbound mail path is inbound-only", | ||
| `MX records point at Cloudflare Email Routing (${mx.join(", ")}), which forwards inbound mail only. Connect a sending provider (Google Workspace, Zoho Mail, Outlook, Resend, Postmark, or SendGrid), enable DKIM there, then save its exact selector as dkimSelector.` | ||
| ); | ||
| } | ||
| } else { | ||
| checks.push({ name: "Outbound mail path", status: "missing", domain: `no MX records for ${domain}` }); | ||
| warn("no MX records for sender domain", "No MX records exist, so replies to the sender address would bounce. Add MX records or use a sender domain that can receive replies before cold email."); | ||
|
Comment on lines
+116
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the configured sender has a postal address and DKIM selector but genuinely has no MX records, this branch makes Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a sender domain has no MX record but its A/AAAA address accepts SMTP, standard mail routing treats that address as an implicit MX target. This branch instead states that replies will bounce and marks setup as failed, so a valid receiving configuration is misdiagnosed; verify the implicit host before emitting the no-reply-path warning. Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| if (config.dkimSelector) { | ||
| const dkimDomain = `${config.dkimSelector}._domainkey.${domain}`; | ||
| const dkimTxt = await txtRecords(dkimDomain); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,12 +96,13 @@ ${configureCommand} | |
|
|
||
| ## Fix Order | ||
|
|
||
| 1. Add a real sender postal address to \`senderPhysicalAddress\` in \`growth-brain/ops/agency-config.json\`. | ||
| 2. In the mail provider for \`${setup.senderDomain || "the sender domain"}\`, enable DKIM and copy the selector. | ||
| 3. Add the selector to \`dkimSelector\` in \`growth-brain/ops/agency-config.json\`. | ||
| 4. If the mail provider gives a DKIM TXT record, add it in Cloudflare DNS at \`${dkimHost}\`. | ||
| 5. Run \`npm run send:setup\`. | ||
| 6. If it is clean, email can join contact forms and DMs as an outbound route. | ||
| 1. Connect an outbound sending provider for \`${setup.senderDomain || "the sender domain"}\` (Cloudflare Email Routing forwards inbound mail only and cannot send). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| 2. Add a real sender postal address to \`senderPhysicalAddress\` in \`growth-brain/ops/agency-config.json\` (business address, PO box, or private mailbox). | ||
| 3. In the outbound provider, enable DKIM and copy the selector. | ||
| 4. Add the provider's DKIM TXT record in Cloudflare DNS at \`${dkimHost}\`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the operator chooses Outlook/Microsoft 365—one of the providers recommended by this change—the provider supplies Useful? React with 👍 / 👎. |
||
| 5. Save the selector as \`dkimSelector\` in \`growth-brain/ops/agency-config.json\`. | ||
| 6. Run \`npm run send:setup\`. | ||
| 7. If it is clean, email can join contact forms and DMs as an outbound route. | ||
|
|
||
| ## Notes | ||
|
|
||
|
|
@@ -233,10 +234,11 @@ const html = `<!doctype html> | |
| <section> | ||
| <h2>Order</h2> | ||
| <ol> | ||
| <li>Add the sender postal address in <code>agency-config.json</code>.</li> | ||
| <li>Enable DKIM in the mail provider and copy the selector.</li> | ||
| <li>Connect an outbound sending provider (Cloudflare Email Routing forwards inbound mail only and cannot send).</li> | ||
| <li>Add the sender postal address in <code>agency-config.json</code> (business address, PO box, or private mailbox).</li> | ||
| <li>Enable DKIM in the outbound provider and copy the selector.</li> | ||
| <li>Add the DKIM TXT record in Cloudflare DNS at <code>${escapeHtml(dkimHost)}</code>.</li> | ||
| <li>Save the selector as <code>dkimSelector</code>.</li> | ||
| <li>Add the DKIM TXT record in Cloudflare if the provider gives one.</li> | ||
| <li>Run <code>npm run send:setup</code>.</li> | ||
| </ol> | ||
| </section> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When DNS is temporarily unavailable or returns
SERVFAIL/ETIMEOUT, this catch converts the resolver failure into an empty result, and the caller consequently states that no MX records exist and replies will bounce. Runningsend:setupor regenerating tracked operator artifacts during a transient resolver failure therefore produces a false configuration diagnosis and potentially instructs the operator to change valid DNS; preserve the error so lookup failure can be reported separately or retried.Useful? React with 👍 / 👎.