hatchkit 0.1.40 → 0.1.42

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.
Files changed (127) hide show
  1. package/dist/adopt.d.ts.map +1 -1
  2. package/dist/adopt.js +663 -82
  3. package/dist/adopt.js.map +1 -1
  4. package/dist/config.d.ts +32 -10
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +91 -38
  7. package/dist/config.js.map +1 -1
  8. package/dist/deploy/coolify-app.d.ts.map +1 -1
  9. package/dist/deploy/coolify-app.js +0 -7
  10. package/dist/deploy/coolify-app.js.map +1 -1
  11. package/dist/deploy/coolify.d.ts.map +1 -1
  12. package/dist/deploy/coolify.js +20 -1
  13. package/dist/deploy/coolify.js.map +1 -1
  14. package/dist/deploy/ghcr.d.ts +4 -2
  15. package/dist/deploy/ghcr.d.ts.map +1 -1
  16. package/dist/deploy/ghcr.js +1 -1
  17. package/dist/deploy/ghcr.js.map +1 -1
  18. package/dist/deploy/github.d.ts +4 -3
  19. package/dist/deploy/github.d.ts.map +1 -1
  20. package/dist/deploy/github.js +5 -2
  21. package/dist/deploy/github.js.map +1 -1
  22. package/dist/deploy/pages.d.ts +41 -0
  23. package/dist/deploy/pages.d.ts.map +1 -1
  24. package/dist/deploy/pages.js +363 -22
  25. package/dist/deploy/pages.js.map +1 -1
  26. package/dist/deploy/regen-infra.d.ts.map +1 -1
  27. package/dist/deploy/regen-infra.js +5 -11
  28. package/dist/deploy/regen-infra.js.map +1 -1
  29. package/dist/deploy/rollback.d.ts.map +1 -1
  30. package/dist/deploy/rollback.js +44 -6
  31. package/dist/deploy/rollback.js.map +1 -1
  32. package/dist/deploy/terraform.d.ts.map +1 -1
  33. package/dist/deploy/terraform.js +20 -37
  34. package/dist/deploy/terraform.js.map +1 -1
  35. package/dist/dns.d.ts.map +1 -1
  36. package/dist/dns.js +4 -5
  37. package/dist/dns.js.map +1 -1
  38. package/dist/doctor.d.ts +15 -0
  39. package/dist/doctor.d.ts.map +1 -1
  40. package/dist/doctor.js +110 -36
  41. package/dist/doctor.js.map +1 -1
  42. package/dist/email/index.d.ts +31 -0
  43. package/dist/email/index.d.ts.map +1 -0
  44. package/dist/email/index.js +251 -0
  45. package/dist/email/index.js.map +1 -0
  46. package/dist/email/presets.d.ts +14 -0
  47. package/dist/email/presets.d.ts.map +1 -0
  48. package/dist/email/presets.js +33 -0
  49. package/dist/email/presets.js.map +1 -0
  50. package/dist/email/setup.d.ts +93 -0
  51. package/dist/email/setup.d.ts.map +1 -0
  52. package/dist/email/setup.js +263 -0
  53. package/dist/email/setup.js.map +1 -0
  54. package/dist/email/spf.d.ts +56 -0
  55. package/dist/email/spf.d.ts.map +1 -0
  56. package/dist/email/spf.js +102 -0
  57. package/dist/email/spf.js.map +1 -0
  58. package/dist/index.js +306 -22
  59. package/dist/index.js.map +1 -1
  60. package/dist/inventory.d.ts +37 -0
  61. package/dist/inventory.d.ts.map +1 -1
  62. package/dist/inventory.js +536 -55
  63. package/dist/inventory.js.map +1 -1
  64. package/dist/overview.d.ts +101 -0
  65. package/dist/overview.d.ts.map +1 -0
  66. package/dist/overview.js +880 -0
  67. package/dist/overview.js.map +1 -0
  68. package/dist/prompts.d.ts +27 -0
  69. package/dist/prompts.d.ts.map +1 -1
  70. package/dist/prompts.js +262 -34
  71. package/dist/prompts.js.map +1 -1
  72. package/dist/provision/index.d.ts +20 -1
  73. package/dist/provision/index.d.ts.map +1 -1
  74. package/dist/provision/index.js +115 -0
  75. package/dist/provision/index.js.map +1 -1
  76. package/dist/provision/s3-buckets.js +1 -1
  77. package/dist/provision/s3-buckets.js.map +1 -1
  78. package/dist/scaffold/app.d.ts.map +1 -1
  79. package/dist/scaffold/app.js +15 -7
  80. package/dist/scaffold/app.js.map +1 -1
  81. package/dist/scaffold/build-pipeline.d.ts +16 -0
  82. package/dist/scaffold/build-pipeline.d.ts.map +1 -1
  83. package/dist/scaffold/build-pipeline.js +47 -4
  84. package/dist/scaffold/build-pipeline.js.map +1 -1
  85. package/dist/scaffold/infra.d.ts +4 -5
  86. package/dist/scaffold/infra.d.ts.map +1 -1
  87. package/dist/scaffold/infra.js +18 -57
  88. package/dist/scaffold/infra.js.map +1 -1
  89. package/dist/scaffold/manifest.d.ts +6 -0
  90. package/dist/scaffold/manifest.d.ts.map +1 -1
  91. package/dist/scaffold/manifest.js +2 -0
  92. package/dist/scaffold/manifest.js.map +1 -1
  93. package/dist/scaffold/pages-heuristics.d.ts +17 -0
  94. package/dist/scaffold/pages-heuristics.d.ts.map +1 -0
  95. package/dist/scaffold/pages-heuristics.js +344 -0
  96. package/dist/scaffold/pages-heuristics.js.map +1 -0
  97. package/dist/scaffold/pages-mode.d.ts +10 -0
  98. package/dist/scaffold/pages-mode.d.ts.map +1 -0
  99. package/dist/scaffold/pages-mode.js +107 -0
  100. package/dist/scaffold/pages-mode.js.map +1 -0
  101. package/dist/scaffold/pkg-json.d.ts +4 -0
  102. package/dist/scaffold/pkg-json.d.ts.map +1 -1
  103. package/dist/scaffold/pkg-json.js +17 -0
  104. package/dist/scaffold/pkg-json.js.map +1 -1
  105. package/dist/scaffold/surfaces.d.ts.map +1 -1
  106. package/dist/scaffold/surfaces.js +12 -1
  107. package/dist/scaffold/surfaces.js.map +1 -1
  108. package/dist/scaffold/update.js +1 -1
  109. package/dist/scaffold/update.js.map +1 -1
  110. package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
  111. package/dist/templates/build-pipeline/docker-compose.yml.hbs +23 -6
  112. package/dist/utils/cloudflare-api.d.ts +158 -13
  113. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  114. package/dist/utils/cloudflare-api.js +219 -11
  115. package/dist/utils/cloudflare-api.js.map +1 -1
  116. package/dist/utils/coolify-api.d.ts +9 -0
  117. package/dist/utils/coolify-api.d.ts.map +1 -1
  118. package/dist/utils/coolify-api.js +26 -0
  119. package/dist/utils/coolify-api.js.map +1 -1
  120. package/dist/utils/run-ledger.d.ts +42 -1
  121. package/dist/utils/run-ledger.d.ts.map +1 -1
  122. package/dist/utils/run-ledger.js.map +1 -1
  123. package/dist/utils/s3-admin.d.ts +9 -0
  124. package/dist/utils/s3-admin.d.ts.map +1 -0
  125. package/dist/utils/s3-admin.js +46 -0
  126. package/dist/utils/s3-admin.js.map +1 -0
  127. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ export interface EmailAddressPreset {
2
+ /** Local part. Joined with `@<domain>` at apply time. */
3
+ localPart: string;
4
+ /** Human-readable description shown in the multi-select prompt. */
5
+ description: string;
6
+ /** Whether this preset is ticked by default in the picker. */
7
+ defaultChecked: boolean;
8
+ }
9
+ export declare const DEFAULT_FORWARD_PRESETS: EmailAddressPreset[];
10
+ /** Whether to enable a catch-all rule (`*@domain` → destination) by
11
+ * default. Catch-all is a safety net for anything not matched by an
12
+ * explicit rule — recommended for personal/operator domains. */
13
+ export declare const DEFAULT_CATCH_ALL = true;
14
+ //# sourceMappingURL=presets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/email/presets.ts"],"names":[],"mappings":"AAsBA,MAAM,WAAW,kBAAkB;IACjC,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,eAAO,MAAM,uBAAuB,EAAE,kBAAkB,EAMvD,CAAC;AAEF;;iEAEiE;AACjE,eAAO,MAAM,iBAAiB,OAAO,CAAC"}
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Email forwarding presets.
3
+ *
4
+ * Default set of local-parts to offer when configuring Cloudflare Email
5
+ * Routing on a new project's zone. The picker is multi-select so the
6
+ * user can untick anything that doesn't apply, or add custom entries.
7
+ *
8
+ * Curated to cover the common public-facing aliases without bloating
9
+ * the rule list (each rule is a distinct Email Routing entry):
10
+ * · hello@ — generic first-touch contact
11
+ * · rico@ — personal alias (the operator's first name)
12
+ * · admin@ — system / infrastructure correspondence (TLS notices,
13
+ * registrar alerts, dotenvx/Github billing receipts)
14
+ * · support@— customer-facing support inbox
15
+ * · hi@ — short personal alternative to hello@
16
+ *
17
+ * A catch-all rule (`*@domain`) is offered separately because
18
+ * Cloudflare's API treats it differently — it's exactly one rule per
19
+ * zone (PUT semantics), not a list. The default is "enable catch-all"
20
+ * so stray addresses (`careers@`, `dmarc@`, …) still reach the user.
21
+ */
22
+ export const DEFAULT_FORWARD_PRESETS = [
23
+ { localPart: "hello", description: "general first-touch contact", defaultChecked: true },
24
+ { localPart: "rico", description: "personal alias", defaultChecked: true },
25
+ { localPart: "admin", description: "infrastructure / system alerts", defaultChecked: true },
26
+ { localPart: "support", description: "customer-facing support", defaultChecked: true },
27
+ { localPart: "hi", description: "short personal alias", defaultChecked: false },
28
+ ];
29
+ /** Whether to enable a catch-all rule (`*@domain` → destination) by
30
+ * default. Catch-all is a safety net for anything not matched by an
31
+ * explicit rule — recommended for personal/operator domains. */
32
+ export const DEFAULT_CATCH_ALL = true;
33
+ //# sourceMappingURL=presets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.js","sourceRoot":"","sources":["../../src/email/presets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAWH,MAAM,CAAC,MAAM,uBAAuB,GAAyB;IAC3D,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,EAAE,6BAA6B,EAAE,cAAc,EAAE,IAAI,EAAE;IACxF,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,gBAAgB,EAAE,cAAc,EAAE,IAAI,EAAE;IAC1E,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,EAAE,gCAAgC,EAAE,cAAc,EAAE,IAAI,EAAE;IAC3F,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,EAAE,yBAAyB,EAAE,cAAc,EAAE,IAAI,EAAE;IACtF,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,sBAAsB,EAAE,cAAc,EAAE,KAAK,EAAE;CAChF,CAAC;AAEF;;iEAEiE;AACjE,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC"}
@@ -0,0 +1,93 @@
1
+ import { type CfEmailDestination } from "../utils/cloudflare-api.js";
2
+ /** Cloudflare-published MX hosts for Email Routing. Verified against
3
+ * what `GET /zones/{id}/email/routing/dns` returns — kept hardcoded
4
+ * so a hatchkit run can describe the records up-front without an
5
+ * extra API call, and because the values have been stable since the
6
+ * Email Routing product launched. The API stays the source of truth
7
+ * in practice: the email module calls `getEmailRoutingDnsRecords()`
8
+ * and uses *those* values, falling back to this list only if CF is
9
+ * unreachable. */
10
+ export declare const CLOUDFLARE_EMAIL_ROUTING_MX: {
11
+ host: string;
12
+ priority: number;
13
+ }[];
14
+ export interface EmailSetupOptions {
15
+ /** Bearer token with Zone:DNS:Edit + Zone:Email Routing Rules:Edit +
16
+ * Account:Email Routing Addresses:Edit. */
17
+ token: string;
18
+ /** Optional Cloudflare account id. Discovered from the zone when
19
+ * absent (since the destinations API is account-scoped, we always
20
+ * need one before the call). */
21
+ accountId?: string;
22
+ /** Apex domain — must match a zone the token can access. */
23
+ domain: string;
24
+ /** Email address that will receive forwarded mail. CF sends a
25
+ * verification email here on first add. */
26
+ destination: string;
27
+ /** Local parts to create forwarding rules for. Each becomes a
28
+ * `<localPart>@<domain>` literal-match rule forwarding to
29
+ * `destination`. Pass an empty list to skip individual rules
30
+ * (still useful with `catchAll: true`). */
31
+ addresses: string[];
32
+ /** Whether to set a catch-all rule (`*@domain` → destination). */
33
+ catchAll: boolean;
34
+ /** SPF includes to merge with Cloudflare's. Use this when the zone
35
+ * already sends via Resend / SES / etc. Pre-existing on-zone
36
+ * records are also auto-merged. */
37
+ extraSpfIncludes?: string[];
38
+ /** DMARC policy. Defaults to "quarantine". */
39
+ dmarcPolicy?: "none" | "quarantine" | "reject";
40
+ /** Aggregate-report destination for DMARC. Defaults to
41
+ * `dmarc@<domain>` (will be caught by the catch-all if enabled).
42
+ * Override when you want CF Email Routing's reports forwarded to
43
+ * a known inbox without relying on catch-all. */
44
+ dmarcRua?: string;
45
+ }
46
+ /** Per-resource report returned by {@link runEmailSetup}. The caller
47
+ * feeds the ledger entries into `RunLedger.record()` for rollback. */
48
+ export interface EmailSetupResult {
49
+ domain: string;
50
+ zoneId: string;
51
+ accountId: string;
52
+ /** True when this run was the one that enabled routing on the zone. */
53
+ routingEnabledThisRun: boolean;
54
+ destination: {
55
+ record: CfEmailDestination;
56
+ /** True when this run created the destination (verification email
57
+ * was just sent). False when it already existed. */
58
+ createdThisRun: boolean;
59
+ /** "active" / "pending" — the user must click the verify link
60
+ * before forwards land. */
61
+ verified: string | null;
62
+ };
63
+ /** DNS records the run created/updated. Each entry's `created` is
64
+ * true when this run added it (use for the rollback ledger). */
65
+ dnsRecords: Array<{
66
+ id: string;
67
+ name: string;
68
+ type: "MX" | "TXT";
69
+ content: string;
70
+ created: boolean;
71
+ updated: boolean;
72
+ }>;
73
+ /** Per-address forwarding rules. */
74
+ rules: Array<{
75
+ address: string;
76
+ id: string;
77
+ created: boolean;
78
+ updated: boolean;
79
+ }>;
80
+ /** Catch-all is a zone-level singleton — toggled, not "created" in
81
+ * the usual sense. `changed` is true when this run flipped its
82
+ * enabled state or forward destination. */
83
+ catchAll?: {
84
+ enabled: boolean;
85
+ changed: boolean;
86
+ };
87
+ }
88
+ export declare function runEmailSetup(opts: EmailSetupOptions): Promise<EmailSetupResult>;
89
+ /** Pretty-print the result for the CLI. Centralised here so both the
90
+ * standalone command and the create/adopt-flow hook print the same
91
+ * status block. */
92
+ export declare function printEmailSetupSummary(result: EmailSetupResult): void;
93
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../src/email/setup.ts"],"names":[],"mappings":"AA2BA,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,4BAA4B,CAAC;AAGpC;;;;;;;mBAOmB;AACnB,eAAO,MAAM,2BAA2B;;;GAIvC,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC;gDAC4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd;;qCAEiC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;gDAC4C;IAC5C,WAAW,EAAE,MAAM,CAAC;IACpB;;;gDAG4C;IAC5C,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAC;IAClB;;wCAEoC;IACpC,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,8CAA8C;IAC9C,WAAW,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,QAAQ,CAAC;IAC/C;;;sDAGkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;uEACuE;AACvE,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,qBAAqB,EAAE,OAAO,CAAC;IAC/B,WAAW,EAAE;QACX,MAAM,EAAE,kBAAkB,CAAC;QAC3B;6DACqD;QACrD,cAAc,EAAE,OAAO,CAAC;QACxB;oCAC4B;QAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;KACzB,CAAC;IACF;qEACiE;IACjE,UAAU,EAAE,KAAK,CAAC;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,IAAI,GAAG,KAAK,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC,CAAC;IACH,oCAAoC;IACpC,KAAK,EAAE,KAAK,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC,CAAC;IACH;;gDAE4C;IAC5C,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;CACnD;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAiKtF;AAgCD;;oBAEoB;AACpB,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAqDrE"}
@@ -0,0 +1,263 @@
1
+ /*
2
+ * Email setup orchestrator.
3
+ *
4
+ * Configures Cloudflare Email Routing end-to-end for one zone:
5
+ * 1. Enable Email Routing on the zone (idempotent).
6
+ * 2. Verify (or add) the destination address — the verification email
7
+ * lands in the user's inbox; they click the link before routing
8
+ * starts working. Skipping verify isn't an option — Cloudflare
9
+ * rejects forwards to unverified destinations.
10
+ * 3. Upsert the receiving MX records (3 Cloudflare hosts).
11
+ * 4. Upsert a single SPF TXT, merging Cloudflare's include with any
12
+ * pre-existing senders (e.g. Resend). One SPF record per zone is
13
+ * the RFC 7208 rule — multiple records cause PermError.
14
+ * 5. Upsert a DMARC TXT at `_dmarc.<zone>`. Default `p=quarantine`
15
+ * with `sp=none` to avoid breaking subdomain senders.
16
+ * 6. Create the forwarding rules — one per `<localPart>@<zone>` →
17
+ * destination. Idempotent: existing rules with matching matchers
18
+ * get their forward updated; otherwise we POST a new rule.
19
+ * 7. Optionally set the catch-all (a single rule per zone, separate
20
+ * endpoint from the rule list).
21
+ *
22
+ * Caller surfaces results via the {@link EmailSetupResult} so the
23
+ * create/adopt run-ledger can record each newly created resource for
24
+ * later rollback.
25
+ */
26
+ import chalk from "chalk";
27
+ import { CloudflareApi, } from "../utils/cloudflare-api.js";
28
+ import { buildDmarcRecord, buildSpfRecord, parseSpfIncludes } from "./spf.js";
29
+ /** Cloudflare-published MX hosts for Email Routing. Verified against
30
+ * what `GET /zones/{id}/email/routing/dns` returns — kept hardcoded
31
+ * so a hatchkit run can describe the records up-front without an
32
+ * extra API call, and because the values have been stable since the
33
+ * Email Routing product launched. The API stays the source of truth
34
+ * in practice: the email module calls `getEmailRoutingDnsRecords()`
35
+ * and uses *those* values, falling back to this list only if CF is
36
+ * unreachable. */
37
+ export const CLOUDFLARE_EMAIL_ROUTING_MX = [
38
+ { host: "route1.mx.cloudflare.net", priority: 1 },
39
+ { host: "route2.mx.cloudflare.net", priority: 2 },
40
+ { host: "route3.mx.cloudflare.net", priority: 3 },
41
+ ];
42
+ export async function runEmailSetup(opts) {
43
+ const cf = new CloudflareApi({ token: opts.token, accountId: opts.accountId });
44
+ const zone = await cf.getZoneByName(opts.domain);
45
+ if (!zone) {
46
+ throw new Error(`No Cloudflare zone for "${opts.domain}". Add the zone to your CF account and re-run.`);
47
+ }
48
+ const accountId = opts.accountId ?? zone.account?.id;
49
+ if (!accountId) {
50
+ throw new Error(`Cloudflare account id could not be resolved for zone ${opts.domain}. Set it via \`hatchkit config add dns\` (advanced — leave blank only when the token spans one account).`);
51
+ }
52
+ // Step 1 — enable routing on the zone. Idempotent; CF returns the
53
+ // current settings on re-enable. We still record whether *this* call
54
+ // flipped enabled=false → true so the rollback ledger doesn't yank
55
+ // a routing setup the user enabled by hand earlier.
56
+ const beforeRouting = await cf.getEmailRouting(zone.id);
57
+ if (!beforeRouting?.enabled) {
58
+ await cf.enableEmailRouting(zone.id);
59
+ }
60
+ const routingEnabledThisRun = !beforeRouting?.enabled;
61
+ // Step 2 — destination address. CF sends a verification email if it
62
+ // didn't already exist.
63
+ const dest = await cf.addEmailDestination(accountId, opts.destination);
64
+ // Step 3 — MX records. Fetch CF's recommended list first (in case the
65
+ // hosts change), fall back to the constant if the call fails.
66
+ let mxRecords = CLOUDFLARE_EMAIL_ROUTING_MX.map((m) => ({
67
+ name: opts.domain,
68
+ content: m.host,
69
+ priority: m.priority,
70
+ }));
71
+ try {
72
+ const recommended = await cf.getEmailRoutingDnsRecords(zone.id);
73
+ const mx = recommended.filter((r) => r.type === "MX");
74
+ if (mx.length > 0) {
75
+ mxRecords = mx.map((r) => ({
76
+ name: r.name,
77
+ content: r.content,
78
+ priority: r.priority ?? 10,
79
+ }));
80
+ }
81
+ }
82
+ catch {
83
+ // Fall back to constants — see the comment on
84
+ // CLOUDFLARE_EMAIL_ROUTING_MX. CF's list is authoritative; the
85
+ // fallback keeps setup working if that one endpoint flakes.
86
+ }
87
+ const dnsRecords = [];
88
+ for (const mx of mxRecords) {
89
+ const res = await cf.upsertRecord(zone.id, {
90
+ type: "MX",
91
+ name: mx.name,
92
+ content: mx.content,
93
+ priority: mx.priority,
94
+ });
95
+ dnsRecords.push({
96
+ id: res.id,
97
+ name: mx.name,
98
+ type: "MX",
99
+ content: mx.content,
100
+ created: res.created,
101
+ updated: res.updated,
102
+ });
103
+ }
104
+ // Step 4 — SPF. Merge Cloudflare's include with whatever's already on
105
+ // the zone (Resend, SES, etc.) and any caller-supplied includes.
106
+ const existingSpf = await findApexSpf(cf, zone.id, opts.domain);
107
+ const mergedIncludes = new Set(["_spf.mx.cloudflare.net"]);
108
+ for (const inc of opts.extraSpfIncludes ?? [])
109
+ mergedIncludes.add(inc);
110
+ if (existingSpf) {
111
+ for (const inc of parseSpfIncludes(existingSpf.content))
112
+ mergedIncludes.add(inc);
113
+ }
114
+ const spfContent = buildSpfRecord({ includes: [...mergedIncludes] });
115
+ // Delete any *other* SPF TXT records first — exactly one SPF record
116
+ // per zone per RFC 7208. The upsert below patches `existingSpf` in
117
+ // place; stale duplicates (zone moved between providers, etc.) get
118
+ // removed here.
119
+ await deleteStaleSpfRecords(cf, zone.id, opts.domain, existingSpf?.id);
120
+ const spfRes = await cf.upsertRecord(zone.id, {
121
+ type: "TXT",
122
+ name: opts.domain,
123
+ content: spfContent,
124
+ });
125
+ dnsRecords.push({
126
+ id: spfRes.id,
127
+ name: opts.domain,
128
+ type: "TXT",
129
+ content: spfContent,
130
+ created: spfRes.created,
131
+ updated: spfRes.updated,
132
+ });
133
+ // Step 5 — DMARC.
134
+ const dmarcName = `_dmarc.${opts.domain}`;
135
+ const dmarcContent = buildDmarcRecord({
136
+ rua: opts.dmarcRua ?? `dmarc@${opts.domain}`,
137
+ policy: opts.dmarcPolicy ?? "quarantine",
138
+ });
139
+ const dmarcRes = await cf.upsertRecord(zone.id, {
140
+ type: "TXT",
141
+ name: dmarcName,
142
+ content: dmarcContent,
143
+ });
144
+ dnsRecords.push({
145
+ id: dmarcRes.id,
146
+ name: dmarcName,
147
+ type: "TXT",
148
+ content: dmarcContent,
149
+ created: dmarcRes.created,
150
+ updated: dmarcRes.updated,
151
+ });
152
+ // Step 6 — per-address forwarding rules.
153
+ const rules = [];
154
+ for (const localPart of opts.addresses) {
155
+ const address = `${localPart}@${opts.domain}`;
156
+ const res = await cf.upsertEmailRoutingRule(zone.id, {
157
+ address,
158
+ forwardTo: [opts.destination],
159
+ name: `Forward ${address}`,
160
+ });
161
+ rules.push({ address, id: res.id, created: res.created, updated: res.updated });
162
+ }
163
+ // Step 7 — catch-all (zone-level singleton). `changed` reflects the
164
+ // catch-all rule itself — its `enabled` flag and forward destination —
165
+ // not the zone-level Email Routing toggle (that's `routingEnabledThisRun`).
166
+ let catchAll;
167
+ if (opts.catchAll) {
168
+ const beforeRule = await cf.getEmailCatchAll(zone.id);
169
+ const beforeEnabled = beforeRule?.enabled ?? false;
170
+ const beforeForward = (beforeRule?.actions?.[0]?.value ?? []).join(",");
171
+ const wantForward = [opts.destination].join(",");
172
+ await cf.setEmailCatchAll(zone.id, {
173
+ forwardTo: [opts.destination],
174
+ enabled: true,
175
+ name: "Catch-all",
176
+ });
177
+ catchAll = { enabled: true, changed: !beforeEnabled || beforeForward !== wantForward };
178
+ }
179
+ return {
180
+ domain: opts.domain,
181
+ zoneId: zone.id,
182
+ accountId,
183
+ routingEnabledThisRun,
184
+ destination: {
185
+ record: dest,
186
+ createdThisRun: !dest.existed,
187
+ verified: dest.verified ?? null,
188
+ },
189
+ dnsRecords,
190
+ rules,
191
+ catchAll,
192
+ };
193
+ }
194
+ /** Find the apex SPF TXT record (one starting with `v=spf1`). Returns
195
+ * null when no SPF record exists yet. There MAY be other TXT records
196
+ * at the apex (verification tokens, etc.) — they're left alone. */
197
+ async function findApexSpf(cf, zoneId, domain) {
198
+ const all = await cf.findRecordsByName(zoneId, domain, "TXT");
199
+ return all.find((r) => /^"?v=spf1\b/i.test(r.content)) ?? null;
200
+ }
201
+ /** Delete every *other* SPF TXT at the apex except the one we're about
202
+ * to upsert. Multiple SPF records cause receivers to PermError per
203
+ * RFC 7208, so a clean zone has exactly one. Skips the record we're
204
+ * keeping (identified by id). */
205
+ async function deleteStaleSpfRecords(cf, zoneId, domain, keepId) {
206
+ const all = await cf.findRecordsByName(zoneId, domain, "TXT");
207
+ for (const rec of all) {
208
+ if (!/^"?v=spf1\b/i.test(rec.content))
209
+ continue;
210
+ if (rec.id === keepId)
211
+ continue;
212
+ await cf.deleteRecord(zoneId, rec.id);
213
+ }
214
+ }
215
+ /** Pretty-print the result for the CLI. Centralised here so both the
216
+ * standalone command and the create/adopt-flow hook print the same
217
+ * status block. */
218
+ export function printEmailSetupSummary(result) {
219
+ console.log(chalk.bold(`\n ── Email setup: ${result.domain} ──────────────────────────\n`));
220
+ if (result.routingEnabledThisRun) {
221
+ console.log(chalk.green(" ✓ Enabled Cloudflare Email Routing on zone"));
222
+ }
223
+ else {
224
+ console.log(chalk.dim(" · Email Routing already enabled"));
225
+ }
226
+ const verifySuffix = result.destination.verified === "active"
227
+ ? chalk.green(" (verified)")
228
+ : chalk.yellow(" (pending — check inbox + click verify link)");
229
+ if (result.destination.createdThisRun) {
230
+ console.log(chalk.green(` ✓ Added destination ${result.destination.record.email}`) + verifySuffix);
231
+ }
232
+ else {
233
+ console.log(chalk.dim(` · Destination ${result.destination.record.email} already on account`) +
234
+ verifySuffix);
235
+ }
236
+ for (const rec of result.dnsRecords) {
237
+ const tag = rec.created
238
+ ? chalk.green("✓ created")
239
+ : rec.updated
240
+ ? chalk.yellow("· updated")
241
+ : chalk.dim("· unchanged");
242
+ console.log(` ${tag} ${rec.type.padEnd(4)} ${rec.name} → ${truncate(rec.content, 60)}`);
243
+ }
244
+ for (const r of result.rules) {
245
+ const tag = r.created
246
+ ? chalk.green("✓ created")
247
+ : r.updated
248
+ ? chalk.yellow("· updated")
249
+ : chalk.dim("· unchanged");
250
+ console.log(` ${tag} rule ${r.address}`);
251
+ }
252
+ if (result.catchAll) {
253
+ const tag = result.catchAll.changed ? chalk.green("✓ enabled") : chalk.dim("· already enabled");
254
+ console.log(` ${tag} catch-all *@${result.domain}`);
255
+ }
256
+ if (result.destination.verified !== "active") {
257
+ console.log(chalk.yellow(`\n ! Forwards won't deliver until ${result.destination.record.email} clicks the Cloudflare verification email.`));
258
+ }
259
+ }
260
+ function truncate(s, n) {
261
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
262
+ }
263
+ //# sourceMappingURL=setup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.js","sourceRoot":"","sources":["../../src/email/setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAGL,aAAa,GACd,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAE9E;;;;;;;mBAOmB;AACnB,MAAM,CAAC,MAAM,2BAA2B,GAAG;IACzC,EAAE,IAAI,EAAE,0BAA0B,EAAE,QAAQ,EAAE,CAAC,EAAE;IACjD,EAAE,IAAI,EAAE,0BAA0B,EAAE,QAAQ,EAAE,CAAC,EAAE;IACjD,EAAE,IAAI,EAAE,0BAA0B,EAAE,QAAQ,EAAE,CAAC,EAAE;CAClD,CAAC;AA2EF,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAuB;IACzD,MAAM,EAAE,GAAG,IAAI,aAAa,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;IAC/E,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACjD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACb,2BAA2B,IAAI,CAAC,MAAM,gDAAgD,CACvF,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC;IACrD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,wDAAwD,IAAI,CAAC,MAAM,0GAA0G,CAC9K,CAAC;IACJ,CAAC;IAED,kEAAkE;IAClE,qEAAqE;IACrE,mEAAmE;IACnE,oDAAoD;IACpD,MAAM,aAAa,GAAG,MAAM,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACxD,IAAI,CAAC,aAAa,EAAE,OAAO,EAAE,CAAC;QAC5B,MAAM,EAAE,CAAC,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACvC,CAAC;IACD,MAAM,qBAAqB,GAAG,CAAC,aAAa,EAAE,OAAO,CAAC;IAEtD,oEAAoE;IACpE,wBAAwB;IACxB,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,mBAAmB,CAAC,SAAS,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAEvE,sEAAsE;IACtE,8DAA8D;IAC9D,IAAI,SAAS,GAAG,2BAA2B,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACtD,IAAI,EAAE,IAAI,CAAC,MAAM;QACjB,OAAO,EAAE,CAAC,CAAC,IAAI;QACf,QAAQ,EAAE,CAAC,CAAC,QAAQ;KACrB,CAAC,CAAC,CAAC;IACJ,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,MAAM,EAAE,CAAC,yBAAyB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAChE,MAAM,EAAE,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;QACtD,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAClB,SAAS,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;gBACzB,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,OAAO,EAAE,CAAC,CAAC,OAAO;gBAClB,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,EAAE;aAC3B,CAAC,CAAC,CAAC;QACN,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,8CAA8C;QAC9C,+DAA+D;QAC/D,4DAA4D;IAC9D,CAAC;IAED,MAAM,UAAU,GAAmC,EAAE,CAAC;IACtD,KAAK,MAAM,EAAE,IAAI,SAAS,EAAE,CAAC;QAC3B,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE;YACzC,IAAI,EAAE,IAAI;YACV,IAAI,EAAE,EAAE,CAAC,IAAI;YACb,OAAO,EAAE,EAAE,CAAC,OAAO;YACnB,QAAQ,EAAE,EAAE,CAAC,QAAQ;SACtB,CAAC,CAAC;QACH,UAAU,CAAC,IAAI,CAAC;YACd,EAAE,EAAE,GAAG,CAAC,EAAE;YACV,IAAI,EAAE,EAAE,CAAC,IAAI;YACb,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,EAAE,CAAC,OAAO;YACnB,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,OAAO,EAAE,GAAG,CAAC,OAAO;SACrB,CAAC,CAAC;IACL,CAAC;IAED,sEAAsE;IACtE,iEAAiE;IACjE,MAAM,WAAW,GAAG,MAAM,WAAW,CAAC,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IAChE,MAAM,cAAc,GAAG,IAAI,GAAG,CAAS,CAAC,wBAAwB,CAAC,CAAC,CAAC;IACnE,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,gBAAgB,IAAI,EAAE;QAAE,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACvE,IAAI,WAAW,EAAE,CAAC;QAChB,KAAK,MAAM,GAAG,IAAI,gBAAgB,CAAC,WAAW,CAAC,OAAO,CAAC;YAAE,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACnF,CAAC;IACD,MAAM,UAAU,GAAG,cAAc,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,cAAc,CAAC,EAAE,CAAC,CAAC;IACrE,oEAAoE;IACpE,mEAAmE;IACnE,mEAAmE;IACnE,gBAAgB;IAChB,MAAM,qBAAqB,CAAC,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC;IACvE,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE;QAC5C,IAAI,EAAE,KAAK;QACX,IAAI,EAAE,IAAI,CAAC,MAAM;QACjB,OAAO,EAAE,UAAU;KACpB,CAAC,CAAC;IACH,UAAU,CAAC,IAAI,CAAC;QACd,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,IAAI,EAAE,IAAI,CAAC,MAAM;QACjB,IAAI,EAAE,KAAK;QACX,OAAO,EAAE,UAAU;QACnB,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,OAAO,EAAE,MAAM,CAAC,OAAO;KACxB,CAAC,CAAC;IAEH,kBAAkB;IAClB,MAAM,SAAS,GAAG,UAAU,IAAI,CAAC,MAAM,EAAE,CAAC;IAC1C,MAAM,YAAY,GAAG,gBAAgB,CAAC;QACpC,GAAG,EAAE,IAAI,CAAC,QAAQ,IAAI,SAAS,IAAI,CAAC,MAAM,EAAE;QAC5C,MAAM,EAAE,IAAI,CAAC,WAAW,IAAI,YAAY;KACzC,CAAC,CAAC;IACH,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE;QAC9C,IAAI,EAAE,KAAK;QACX,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,YAAY;KACtB,CAAC,CAAC;IACH,UAAU,CAAC,IAAI,CAAC;QACd,EAAE,EAAE,QAAQ,CAAC,EAAE;QACf,IAAI,EAAE,SAAS;QACf,IAAI,EAAE,KAAK;QACX,OAAO,EAAE,YAAY;QACrB,OAAO,EAAE,QAAQ,CAAC,OAAO;QACzB,OAAO,EAAE,QAAQ,CAAC,OAAO;KAC1B,CAAC,CAAC;IAEH,yCAAyC;IACzC,MAAM,KAAK,GAA8B,EAAE,CAAC;IAC5C,KAAK,MAAM,SAAS,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;QACvC,MAAM,OAAO,GAAG,GAAG,SAAS,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAC9C,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,sBAAsB,CAAC,IAAI,CAAC,EAAE,EAAE;YACnD,OAAO;YACP,SAAS,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC;YAC7B,IAAI,EAAE,WAAW,OAAO,EAAE;SAC3B,CAAC,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;IAClF,CAAC;IAED,oEAAoE;IACpE,uEAAuE;IACvE,4EAA4E;IAC5E,IAAI,QAAsC,CAAC;IAC3C,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAClB,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACtD,MAAM,aAAa,GAAG,UAAU,EAAE,OAAO,IAAI,KAAK,CAAC;QACnD,MAAM,aAAa,GAAG,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACxE,MAAM,WAAW,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACjD,MAAM,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,EAAE;YACjC,SAAS,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC;YAC7B,OAAO,EAAE,IAAI;YACb,IAAI,EAAE,WAAW;SAClB,CAAC,CAAC;QACH,QAAQ,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,aAAa,IAAI,aAAa,KAAK,WAAW,EAAE,CAAC;IACzF,CAAC;IAED,OAAO;QACL,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,MAAM,EAAE,IAAI,CAAC,EAAE;QACf,SAAS;QACT,qBAAqB;QACrB,WAAW,EAAE;YACX,MAAM,EAAE,IAAI;YACZ,cAAc,EAAE,CAAC,IAAI,CAAC,OAAO;YAC7B,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,IAAI;SAChC;QACD,UAAU;QACV,KAAK;QACL,QAAQ;KACT,CAAC;AACJ,CAAC;AAED;;oEAEoE;AACpE,KAAK,UAAU,WAAW,CACxB,EAAiB,EACjB,MAAc,EACd,MAAc;IAEd,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAC9D,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,IAAI,CAAC;AACjE,CAAC;AAED;;;kCAGkC;AAClC,KAAK,UAAU,qBAAqB,CAClC,EAAiB,EACjB,MAAc,EACd,MAAc,EACd,MAA0B;IAE1B,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAC9D,KAAK,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACtB,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,SAAS;QAChD,IAAI,GAAG,CAAC,EAAE,KAAK,MAAM;YAAE,SAAS;QAChC,MAAM,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IACxC,CAAC;AACH,CAAC;AAED;;oBAEoB;AACpB,MAAM,UAAU,sBAAsB,CAAC,MAAwB;IAC7D,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,uBAAuB,MAAM,CAAC,MAAM,+BAA+B,CAAC,CAAC,CAAC;IAC7F,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QACjC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC,CAAC;IAC3E,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC,CAAC;IAC9D,CAAC;IAED,MAAM,YAAY,GAChB,MAAM,CAAC,WAAW,CAAC,QAAQ,KAAK,QAAQ;QACtC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC;QAC5B,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,8CAA8C,CAAC,CAAC;IACnE,IAAI,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,CAAC;QACtC,OAAO,CAAC,GAAG,CACT,KAAK,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,GAAG,YAAY,CACvF,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CACT,KAAK,CAAC,GAAG,CAAC,mBAAmB,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,qBAAqB,CAAC;YAChF,YAAY,CACf,CAAC;IACJ,CAAC;IAED,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;QACpC,MAAM,GAAG,GAAG,GAAG,CAAC,OAAO;YACrB,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC;YAC1B,CAAC,CAAC,GAAG,CAAC,OAAO;gBACX,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC;gBAC3B,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC/B,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,IAAI,QAAQ,QAAQ,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC;IAC7F,CAAC;IAED,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,CAAC,CAAC,OAAO;YACnB,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC;YAC1B,CAAC,CAAC,CAAC,CAAC,OAAO;gBACT,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC;gBAC3B,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC/B,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;IAC5C,CAAC;IAED,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACpB,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;QAChG,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,gBAAgB,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IACvD,CAAC;IAED,IAAI,MAAM,CAAC,WAAW,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC7C,OAAO,CAAC,GAAG,CACT,KAAK,CAAC,MAAM,CACV,sCAAsC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,4CAA4C,CAClH,CACF,CAAC;IACJ,CAAC;AACH,CAAC;AAED,SAAS,QAAQ,CAAC,CAAS,EAAE,CAAS;IACpC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;AACpD,CAAC"}
@@ -0,0 +1,56 @@
1
+ /** Common SPF includes for popular senders. The Resend value is the
2
+ * one their dashboard tells you to add; AWS SES users would substitute
3
+ * `amazonses.com` etc. Hatchkit auto-includes Cloudflare's host; other
4
+ * senders come from configuration. */
5
+ export declare const SPF_INCLUDES: {
6
+ readonly cloudflareEmailRouting: "_spf.mx.cloudflare.net";
7
+ readonly resend: "_spf.resend.com";
8
+ readonly amazonSes: "amazonses.com";
9
+ readonly google: "_spf.google.com";
10
+ };
11
+ export type SpfQualifier = "~all" | "-all" | "?all" | "+all";
12
+ /** Build a single SPF TXT record content string. Sorts includes for
13
+ * deterministic output (matters for idempotent upserts: same input =
14
+ * same record content = no PATCH on re-run). */
15
+ export declare function buildSpfRecord(opts: {
16
+ /** Hostnames to wrap as `include:<host>`. Order-insensitive. */
17
+ includes: string[];
18
+ /** Optional `ip4:` / `ip6:` mechanisms. */
19
+ ip4?: string[];
20
+ ip6?: string[];
21
+ /** Default `~all` — see file header. */
22
+ qualifier?: SpfQualifier;
23
+ }): string;
24
+ /** Parse the includes out of an existing SPF record so we can union
25
+ * them with new ones (e.g. when adding Cloudflare to a zone that
26
+ * already has Resend). Lenient: returns an empty list for malformed
27
+ * input rather than throwing — the caller can decide whether to
28
+ * overwrite or surface the parse failure. */
29
+ export declare function parseSpfIncludes(record: string): string[];
30
+ /** Build a DMARC TXT record content string.
31
+ *
32
+ * Default policy is `p=quarantine` — failed-DMARC mail lands in spam
33
+ * rather than the inbox. `sp=none` deliberately exempts subdomains
34
+ * (e.g. `staging.<domain>`) because they often send via third-party
35
+ * services not aligned with the apex. `rua` is the aggregate-report
36
+ * destination — same address as the forwarding inbox by default, so
37
+ * the operator gets weekly delivery summaries.
38
+ *
39
+ * Tweaks (call sites can override):
40
+ * · `policy: "none"` — observe-only, no enforcement. Use during
41
+ * a soft rollout when you're not sure every legit sender is
42
+ * aligned yet.
43
+ * · `subdomainPolicy: "quarantine"` — extend enforcement to
44
+ * subdomains. Only safe once those subdomains' senders are known. */
45
+ export declare function buildDmarcRecord(opts: {
46
+ rua: string;
47
+ policy?: "none" | "quarantine" | "reject";
48
+ subdomainPolicy?: "none" | "quarantine" | "reject";
49
+ /** Percentage of mail subject to policy (0-100). Default 100. Drop
50
+ * to e.g. 20 during a soft rollout. */
51
+ percent?: number;
52
+ /** SPF/DKIM alignment mode — strict ("s") or relaxed ("r"). */
53
+ alignmentSpf?: "s" | "r";
54
+ alignmentDkim?: "s" | "r";
55
+ }): string;
56
+ //# sourceMappingURL=spf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spf.d.ts","sourceRoot":"","sources":["../../src/email/spf.ts"],"names":[],"mappings":"AAgCA;;;uCAGuC;AACvC,eAAO,MAAM,YAAY;;;;;CAKf,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAE7D;;iDAEiD;AACjD,wBAAgB,cAAc,CAAC,IAAI,EAAE;IACnC,gEAAgE;IAChE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,YAAY,CAAC;CAC1B,GAAG,MAAM,CAUT;AAED;;;;8CAI8C;AAC9C,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CASzD;AAED;;;;;;;;;;;;;;yEAcyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,QAAQ,CAAC;IAC1C,eAAe,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,QAAQ,CAAC;IACnD;4CACwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,aAAa,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CAC3B,GAAG,MAAM,CAWT"}
@@ -0,0 +1,102 @@
1
+ /*
2
+ * SPF + DMARC TXT-record helpers.
3
+ *
4
+ * SPF rules-of-the-road that make this small but important:
5
+ *
6
+ * 1. **One SPF TXT per domain.** RFC 7208 §3.2 — multiple records
7
+ * cause receivers to PermError. So if Resend's records already
8
+ * include `v=spf1 …` and Cloudflare Email Routing wants its own
9
+ * `v=spf1 …`, they MUST be merged into ONE record, not added as
10
+ * siblings.
11
+ *
12
+ * 2. **Maximum 10 DNS lookups inside the SPF chain.** Each `include:`
13
+ * counts as one lookup. Cloudflare Email Routing's
14
+ * `_spf.mx.cloudflare.net` and (e.g.) Resend's
15
+ * `_spf.resend.com` each cost one — well under the cap, but worth
16
+ * knowing if a user later piles on more senders.
17
+ *
18
+ * 3. **Order of `include:` mechanisms does NOT matter for the verdict,
19
+ * but a stable order makes hatchkit's idempotent upserts a no-op
20
+ * on re-run.** We sort the includes alphabetically before joining.
21
+ *
22
+ * 4. **Qualifier (`~all` vs `-all`):** we default to `~all` (softfail)
23
+ * — recipients see a soft signal that mail from unauthorised IPs
24
+ * is suspicious but they still accept it. This pairs well with
25
+ * DMARC at `p=quarantine`, which makes the final disposition call
26
+ * via alignment rather than SPF-fail-alone. Upgrade to `-all`
27
+ * (hardfail) once you're confident no legitimate sender is missing
28
+ * from the include list.
29
+ */
30
+ const CLOUDFLARE_EMAIL_ROUTING_SPF_INCLUDE = "_spf.mx.cloudflare.net";
31
+ /** Common SPF includes for popular senders. The Resend value is the
32
+ * one their dashboard tells you to add; AWS SES users would substitute
33
+ * `amazonses.com` etc. Hatchkit auto-includes Cloudflare's host; other
34
+ * senders come from configuration. */
35
+ export const SPF_INCLUDES = {
36
+ cloudflareEmailRouting: CLOUDFLARE_EMAIL_ROUTING_SPF_INCLUDE,
37
+ resend: "_spf.resend.com",
38
+ amazonSes: "amazonses.com",
39
+ google: "_spf.google.com",
40
+ };
41
+ /** Build a single SPF TXT record content string. Sorts includes for
42
+ * deterministic output (matters for idempotent upserts: same input =
43
+ * same record content = no PATCH on re-run). */
44
+ export function buildSpfRecord(opts) {
45
+ const includes = [...new Set(opts.includes)].sort();
46
+ const ip4 = opts.ip4 ? [...new Set(opts.ip4)].sort() : [];
47
+ const ip6 = opts.ip6 ? [...new Set(opts.ip6)].sort() : [];
48
+ const parts = ["v=spf1"];
49
+ for (const i of ip4)
50
+ parts.push(`ip4:${i}`);
51
+ for (const i of ip6)
52
+ parts.push(`ip6:${i}`);
53
+ for (const inc of includes)
54
+ parts.push(`include:${inc}`);
55
+ parts.push(opts.qualifier ?? "~all");
56
+ return parts.join(" ");
57
+ }
58
+ /** Parse the includes out of an existing SPF record so we can union
59
+ * them with new ones (e.g. when adding Cloudflare to a zone that
60
+ * already has Resend). Lenient: returns an empty list for malformed
61
+ * input rather than throwing — the caller can decide whether to
62
+ * overwrite or surface the parse failure. */
63
+ export function parseSpfIncludes(record) {
64
+ const trimmed = record.trim().replace(/^"|"$/g, "").trim();
65
+ if (!/^v=spf1\b/i.test(trimmed))
66
+ return [];
67
+ const includes = [];
68
+ for (const part of trimmed.split(/\s+/)) {
69
+ const m = part.match(/^include:(.+)$/i);
70
+ if (m)
71
+ includes.push(m[1].toLowerCase());
72
+ }
73
+ return includes;
74
+ }
75
+ /** Build a DMARC TXT record content string.
76
+ *
77
+ * Default policy is `p=quarantine` — failed-DMARC mail lands in spam
78
+ * rather than the inbox. `sp=none` deliberately exempts subdomains
79
+ * (e.g. `staging.<domain>`) because they often send via third-party
80
+ * services not aligned with the apex. `rua` is the aggregate-report
81
+ * destination — same address as the forwarding inbox by default, so
82
+ * the operator gets weekly delivery summaries.
83
+ *
84
+ * Tweaks (call sites can override):
85
+ * · `policy: "none"` — observe-only, no enforcement. Use during
86
+ * a soft rollout when you're not sure every legit sender is
87
+ * aligned yet.
88
+ * · `subdomainPolicy: "quarantine"` — extend enforcement to
89
+ * subdomains. Only safe once those subdomains' senders are known. */
90
+ export function buildDmarcRecord(opts) {
91
+ const parts = [
92
+ "v=DMARC1",
93
+ `p=${opts.policy ?? "quarantine"}`,
94
+ `sp=${opts.subdomainPolicy ?? "none"}`,
95
+ `rua=mailto:${opts.rua}`,
96
+ `pct=${opts.percent ?? 100}`,
97
+ `adkim=${opts.alignmentDkim ?? "r"}`,
98
+ `aspf=${opts.alignmentSpf ?? "r"}`,
99
+ ];
100
+ return parts.join("; ");
101
+ }
102
+ //# sourceMappingURL=spf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spf.js","sourceRoot":"","sources":["../../src/email/spf.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,oCAAoC,GAAG,wBAAwB,CAAC;AAEtE;;;uCAGuC;AACvC,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,sBAAsB,EAAE,oCAAoC;IAC5D,MAAM,EAAE,iBAAiB;IACzB,SAAS,EAAE,eAAe;IAC1B,MAAM,EAAE,iBAAiB;CACjB,CAAC;AAIX;;iDAEiD;AACjD,MAAM,UAAU,cAAc,CAAC,IAQ9B;IACC,MAAM,QAAQ,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACpD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1D,MAAM,KAAK,GAAG,CAAC,QAAQ,CAAC,CAAC;IACzB,KAAK,MAAM,CAAC,IAAI,GAAG;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC5C,KAAK,MAAM,CAAC,IAAI,GAAG;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC5C,KAAK,MAAM,GAAG,IAAI,QAAQ;QAAE,KAAK,CAAC,IAAI,CAAC,WAAW,GAAG,EAAE,CAAC,CAAC;IACzD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,MAAM,CAAC,CAAC;IACrC,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzB,CAAC;AAED;;;;8CAI8C;AAC9C,MAAM,UAAU,gBAAgB,CAAC,MAAc;IAC7C,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC3D,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAC3C,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;QACxC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;QACxC,IAAI,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;;;;;;;;;;;yEAcyE;AACzE,MAAM,UAAU,gBAAgB,CAAC,IAUhC;IACC,MAAM,KAAK,GAAa;QACtB,UAAU;QACV,KAAK,IAAI,CAAC,MAAM,IAAI,YAAY,EAAE;QAClC,MAAM,IAAI,CAAC,eAAe,IAAI,MAAM,EAAE;QACtC,cAAc,IAAI,CAAC,GAAG,EAAE;QACxB,OAAO,IAAI,CAAC,OAAO,IAAI,GAAG,EAAE;QAC5B,SAAS,IAAI,CAAC,aAAa,IAAI,GAAG,EAAE;QACpC,QAAQ,IAAI,CAAC,YAAY,IAAI,GAAG,EAAE;KACnC,CAAC;IACF,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC"}