dkimkit 0.6.1 → 0.7.0

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.
@@ -0,0 +1,253 @@
1
+ # Node.js library and CLI
2
+
3
+ The `dkimkit` npm package runs the whole setup from your own code or terminal: it gets the records from your email provider, writes them to your DNS host, waits until they're live, has the provider verify the domain, and optionally sets up inbound email.
4
+
5
+ It runs entirely on your machine with your own provider keys. It doesn't call {{APP_NAME}}'s servers, so there's no account, API key, plan or domain limit. Only your email and DNS providers' own rate limits apply. Want us to run it for you, with a dashboard, webhooks and an embeddable widget? Use the [hosted API](/docs/hosted-api) instead.
6
+
7
+ | | Library and CLI | Hosted API |
8
+ |---|---|---|
9
+ | Runs | In your process | On {{APP_NAME}} |
10
+ | Domains | No limit | Set by your plan |
11
+ | Waits for DNS and verification | While your code runs | In the background, then sends a webhook |
12
+ | Provider keys | Stay with you | Stored encrypted |
13
+ | UI for your customers | Build your own | Embeddable widget |
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install dkimkit
19
+ ```
20
+
21
+ Requires Node 20 or later. The package has no runtime dependencies and ships its own TypeScript types.
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { setupDomain, createEmailProvider, createDnsProvider } from 'dkimkit';
27
+
28
+ const email = createEmailProvider('sendgrid', { apiKey: process.env.SENDGRID_API_KEY });
29
+ const dns = createDnsProvider('cloudflare', { cloudflareApiToken: process.env.CLOUDFLARE_API_TOKEN });
30
+
31
+ const result = await setupDomain(email, dns, {
32
+ domain: 'example.com',
33
+ onEvent: (e) => console.log(e.type),
34
+ });
35
+
36
+ if (result.status === 'complete') console.log('example.com is authenticated');
37
+ else console.log('Not finished yet:', result.status);
38
+ ```
39
+
40
+ Every step is idempotent: existing provider domains and DNS records are reused. If a run ends with `dns-pending` or `validation-failed`, run it again later and it picks up where it stopped.
41
+
42
+ ## setupDomain(email, dns, options)
43
+
44
+ Runs the full flow and resolves with a result once the domain is verified, or when it can't go further yet.
45
+
46
+ 1. **Domain.** Reuses the provider's existing authentication for the domain, or creates one.
47
+ 2. **Plan.** Compares the records the provider needs with what's already in DNS. Matching records are left alone. A record pointing somewhere else is a conflict, and nothing is written.
48
+ 3. **Apply.** Writes the new and changed records.
49
+ 4. **Propagation.** Polls the domain's authoritative nameservers until every record is visible.
50
+ 5. **Validation.** Asks the provider to verify, retrying because its resolvers can lag behind.
51
+ 6. **Inbound.** If you asked for it, registers the inbound webhook once the domain is verified.
52
+
53
+ ### Options
54
+
55
+ | Option | Type | |
56
+ |---|---|---|
57
+ | `domain` | `string` | The domain to authenticate. Required |
58
+ | `subdomain` | `string` | Return-path or MAIL FROM label. SendGrid picks one by default, Postmark uses `pm-bounces`, SES uses `mail`. Not used by Mailgun |
59
+ | `inbound` | `{ hostname, url, spamCheck?, sendRaw? }` | Receive email at `hostname` and POST it to `url`. Not supported for Amazon SES |
60
+ | `overwrite` | `boolean` | Replace CNAME and MX records that point elsewhere instead of stopping with a conflict |
61
+ | `dryRun` | `boolean` | Plan only and write nothing to DNS. The provider may still create its domain entry, since that's the only way to get the records |
62
+ | `confirm` | `(changes) => Promise<boolean>` | Called before writing DNS when there are changes. Return `false` to stop with status `aborted` |
63
+ | `propagation` | `{ timeoutMs?, intervalMs?, lookup? }` | How long to wait for DNS. Defaults: 300000 ms, polling every 5000 ms |
64
+ | `validation` | `{ attempts?, intervalMs? }` | Verification retries. Defaults: 6 attempts, 10000 ms apart |
65
+ | `onEvent` | `(event) => void` | Progress, for logs or a live UI. See [Events](#events) |
66
+ | `signal` | `AbortSignal` | Stops waiting and rejects with an `AbortError` |
67
+
68
+ ### Result
69
+
70
+ ```ts
71
+ const result = await setupDomain(email, dns, { domain: 'example.com' });
72
+ result.status; // 'complete' | 'dns-pending' | 'validation-failed' | 'aborted' | 'dry-run'
73
+ result.domain; // { id, domain, valid, records, created }
74
+ result.changes; // the DNS plan: [{ record, action: 'create' | 'update' | 'noop' | 'conflict' }]
75
+ result.pending; // dns-pending: records that weren't visible before the timeout
76
+ result.validation; // the provider's last answer: { valid, results: [{ record, valid, reason? }] }
77
+ result.inbound; // 'created' | 'updated' | 'unchanged', when inbound was set up
78
+ ```
79
+
80
+ | Status | Meaning |
81
+ |---|---|
82
+ | `complete` | Verified, and inbound set up if requested |
83
+ | `dns-pending` | Records were written but aren't visible yet. Run again later |
84
+ | `validation-failed` | Records are visible but the provider hasn't verified them yet. Run again later |
85
+ | `aborted` | `confirm` returned `false`. Nothing was written |
86
+ | `dry-run` | `dryRun` was set. `changes` shows what would be written |
87
+
88
+ ### Events
89
+
90
+ `onEvent` is called as each step happens:
91
+
92
+ | `type` | Fields |
93
+ |---|---|
94
+ | `domain` | `auth`: the provider's domain and the records it needs |
95
+ | `plan` | `changes`: what will be created, updated, left alone, or conflicts |
96
+ | `applied` | `changes`: what was written |
97
+ | `propagation` | `pending`, `total`, `elapsedMs`, after each DNS check |
98
+ | `validation` | `attempt`, `result`, after each verification attempt |
99
+ | `inbound` | `hostname`, `url`, `action` |
100
+
101
+ For example, to stream progress to your own UI per customer:
102
+
103
+ ```ts
104
+ await setupDomain(email, dns, {
105
+ domain: tenant.domain,
106
+ inbound: { hostname: `parse.${tenant.domain}`, url: `https://app.example.com/inbound/${tenant.id}` },
107
+ onEvent: (e) => {
108
+ if (e.type === 'propagation') progress(tenant.id, `${e.total - e.pending.length} of ${e.total} records live`);
109
+ if (e.type === 'validation' && e.result.valid) progress(tenant.id, 'Verified');
110
+ },
111
+ });
112
+ ```
113
+
114
+ ## checkStatus(email, domain, subdomain?)
115
+
116
+ A read-only check that changes nothing. It finds the domain at the provider and asks it to re-validate:
117
+
118
+ ```ts
119
+ import { checkStatus } from 'dkimkit';
120
+
121
+ const { domain, validation } = await checkStatus(email, 'example.com');
122
+ if (!domain) console.log('Not set up with this provider yet');
123
+ else if (validation?.valid) console.log('Verified');
124
+ else console.log('Not verified yet:', validation?.results.filter((r) => !r.valid));
125
+ ```
126
+
127
+ ## Email providers
128
+
129
+ Create one with `createEmailProvider(name, credentials, options?)`:
130
+
131
+ | Provider | Name | Credentials | Records |
132
+ |---|---|---|---|
133
+ | SendGrid | `sendgrid` | `apiKey`, with Sender Authentication and Inbound Parse access | 3 CNAMEs |
134
+ | Postmark | `postmark` | `accountToken` (an account token, not a server token), and `inboundServerId` to receive email | DKIM TXT and Return-Path CNAME |
135
+ | Mailgun | `mailgun` | `apiKey`, and `region`: `us` (default) or `eu` | SPF and DKIM TXT, tracking CNAME |
136
+ | Amazon SES | `ses` | `accessKeyId`, `secretAccessKey`, optional `sessionToken`, and `region`, e.g. `us-east-1` | 3 DKIM CNAMEs, MAIL FROM MX and SPF |
137
+
138
+ ```ts
139
+ const sendgrid = createEmailProvider('sendgrid', { apiKey }, { subuser: tenant.sendgridSubuser });
140
+ const mailgun = createEmailProvider('mailgun', { apiKey, region: 'eu' });
141
+ const ses = createEmailProvider('ses', { accessKeyId, secretAccessKey, region: 'us-east-1' });
142
+ ```
143
+
144
+ The `subuser` option runs SendGrid calls on behalf of a subuser, which keeps each customer's domains in their own SendGrid subuser. Missing or invalid credentials throw a `ConfigError` before any request is made.
145
+
146
+ Each provider also has its own class (`SendGridProvider`, `PostmarkProvider`, `MailgunProvider`, `SesProvider`) if you'd rather construct it directly.
147
+
148
+ When a provider needs an SPF record and the name already has one, the provider is added to the existing record instead of creating a second one, which would make SPF fail.
149
+
150
+ ## DNS providers
151
+
152
+ Create one with `createDnsProvider(name, config?)`:
153
+
154
+ | DNS host | Name | Config |
155
+ |---|---|---|
156
+ | Cloudflare | `cloudflare` | `cloudflareApiToken` (or `CLOUDFLARE_API_TOKEN`), with Zone:Read and DNS:Edit on the zone |
157
+ | Amazon Route 53 | `route53` | `aws: { accessKeyId, secretAccessKey, sessionToken? }`, or `awsProfile`, or the usual AWS environment variables. Optional `route53HostedZoneId` |
158
+ | Any other host | `manual` | `onManualRecords(records)`: show the records to whoever adds them |
159
+
160
+ Route 53 credentials need `route53:ListHostedZonesByName`, `route53:ListResourceRecordSets` and `route53:ChangeResourceRecordSets`. Records are merged into existing record sets in one atomic change, so an existing TXT record keeps its other values.
161
+
162
+ ### Records added by hand
163
+
164
+ With `manual`, `setupDomain` hands the records to your callback and then waits for them to appear in DNS, so give whoever adds them enough time with `propagation.timeoutMs`:
165
+
166
+ ```ts
167
+ import { createDnsProvider, describeRecord, detectDnsHost } from 'dkimkit';
168
+
169
+ const host = await detectDnsHost('example.com'); // { name: 'GoDaddy', provider: null, nameservers: [...] }
170
+ const dns = createDnsProvider('manual', {
171
+ onManualRecords: async (records) => {
172
+ console.log(`Add these records at ${host.name ?? 'your DNS host'}:`);
173
+ for (const r of records) console.log(' ', describeRecord(r));
174
+ },
175
+ });
176
+ const result = await setupDomain(email, dns, { domain: 'example.com', propagation: { timeoutMs: 30 * 60_000 } });
177
+ ```
178
+
179
+ `detectDnsHost(domain)` recognizes common DNS hosts from the nameservers. Its `provider` is `cloudflare` or `route53` when records can be written automatically there, and `null` otherwise. It never throws.
180
+
181
+ ## Errors
182
+
183
+ | Error | When | Useful fields |
184
+ |---|---|---|
185
+ | `ConfigError` | Missing or invalid credentials or options | `message` |
186
+ | `DnsConflictError` | A record the provider needs already points somewhere else. Nothing was written | `conflicts`: the conflicting planned changes |
187
+ | `ApiError` | An email or DNS provider rejected a request | `provider`, `status`, `body` |
188
+
189
+ ```ts
190
+ import { ApiError, DnsConflictError, apiErrorHint } from 'dkimkit';
191
+
192
+ try {
193
+ await setupDomain(email, dns, { domain: 'example.com' });
194
+ } catch (err) {
195
+ if (err instanceof DnsConflictError) console.log(err.message); // lists each conflict; pass overwrite: true to replace them
196
+ else if (err instanceof ApiError) console.log(err.message, apiErrorHint(err) ?? '');
197
+ else throw err;
198
+ }
199
+ ```
200
+
201
+ `apiErrorHint(err)` returns what to check when a provider rejects the credentials (401 or 403), such as the permissions a key needs.
202
+
203
+ ## Verifying webhooks
204
+
205
+ If you also use the [hosted API](/docs/hosted-api#webhooks), the package verifies its webhook signatures:
206
+
207
+ ```ts
208
+ import { verifyWebhookSignature } from 'dkimkit';
209
+
210
+ const ok = verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, rawBody, req.headers['x-dkimkit-signature']);
211
+ ```
212
+
213
+ Pass the raw request body, before any JSON parsing. Requests older than 5 minutes are rejected as replays.
214
+
215
+ ## CLI
216
+
217
+ The same flow from a terminal, with no code:
218
+
219
+ ```bash
220
+ export SENDGRID_API_KEY=SG.xxxx
221
+ export CLOUDFLARE_API_TOKEN=xxxx
222
+
223
+ npx dkimkit --domain example.com --inbound-url https://api.example.com/webhooks/inbound-email
224
+ ```
225
+
226
+ It prints the DNS plan, asks before writing, then shows each step until the domain is verified.
227
+
228
+ | Command | |
229
+ |---|---|
230
+ | `dkimkit --domain example.com` | Set up a domain |
231
+ | `dkimkit status --domain example.com` | Show the provider's verification status without changing anything |
232
+ | `dkimkit ui` | Open a local web interface for the same flow |
233
+
234
+ | Option | |
235
+ |---|---|
236
+ | `-d, --domain` | Domain to authenticate |
237
+ | `--provider` | `sendgrid` (default), `postmark`, `mailgun` or `ses` |
238
+ | `--region` | Mailgun `us` or `eu`, or the Amazon SES region |
239
+ | `--subdomain` | Return-path or MAIL FROM label |
240
+ | `--dns` | `cloudflare`, `route53` or `manual`. Defaults to `cloudflare` when `CLOUDFLARE_API_TOKEN` is set |
241
+ | `--inbound-url`, `--inbound-host` | Receive email and POST it to this URL. The host defaults to `parse.<domain>` (Mailgun: the domain itself) |
242
+ | `--spam-check`, `--send-raw` | Inbound options |
243
+ | `--overwrite` | Replace CNAME and MX records that point elsewhere |
244
+ | `--dry-run` | Show the DNS plan without writing anything |
245
+ | `--wait` | Seconds to wait for DNS to propagate (default 300) |
246
+ | `--on-behalf-of` | Run as a SendGrid subuser |
247
+ | `--aws-profile`, `--zone-id` | Route 53 profile and hosted zone |
248
+ | `-y, --yes` | Write DNS without asking |
249
+ | `--json` | Print the result as JSON, for scripts |
250
+
251
+ Credentials come from the environment: `SENDGRID_API_KEY`, `POSTMARK_ACCOUNT_TOKEN` and `POSTMARK_INBOUND_SERVER_ID`, `MAILGUN_API_KEY` and `MAILGUN_REGION`, `AWS_REGION` for SES, `CLOUDFLARE_API_TOKEN`, and the usual AWS variables or `~/.aws/credentials` for Route 53 and SES.
252
+
253
+ Exit codes: `0` done, `1` error, `2` not finished yet (DNS pending, not verified yet, or you declined the changes). Run the same command again to continue.
@@ -55,7 +55,7 @@ npx dkimkit-api set-domain-limit --account acct_… --limit 25 # or
55
55
  SENDGRID_API_KEY=SG.xxx npx dkimkit-api create-account --name "Acme" --with-sendgrid-key # operator-created account
56
56
  ```
57
57
 
58
- The limit counts an account's current domains in any status. It's checked before anything is created at SendGrid, so a rejected request leaves nothing behind. Over the limit, `POST /v1/domains` returns `402 plan_limit_reached`. In the widget, end users see a neutral "contact support" message instead. The limit is soft: two creates at the same instant can both pass.
58
+ The limit counts an account's current domains in any status. It's checked before anything is created at SendGrid, so a rejected request leaves nothing behind. Over the limit, `POST /v1/domains` returns `402 plan_limit_reached`. In the widget, end users see a neutral "contact support" message instead. Creates for a limited account run one at a time within a server process, so a bulk add can't overshoot. With several API instances the limit is soft: creates in different processes at the same instant can both pass.
59
59
 
60
60
 
61
61
  ## Website and legal pages
@@ -66,12 +66,16 @@ The service serves a public site when `PUBLIC_URL` is set: a landing page at `/`
66
66
  |---|---|
67
67
  | `APP_NAME` | Product name everywhere (default "DKIMkit") |
68
68
  | `LEGAL_NAME` | Who operates the service: your name or business name, on About and the legal pages |
69
- | `CONTACT_EMAIL` | Support and legal contact |
69
+ | `CONTACT_EMAIL` | Support and legal contact. Messages from the website's feedback form are emailed here (with the sign-in mailer) |
70
70
  | `LEGAL_COUNTRY` | Country whose law governs the terms (e.g. "India") |
71
71
  | `LEGAL_EFFECTIVE_DATE` | "Last updated" date on the legal pages (default: the build's date) |
72
72
 
73
73
  Until they're set, the pages show visible placeholders. The legal pages are drafts written for this product. Have them reviewed by someone qualified before relying on them.
74
74
 
75
+ ## Free plan before payments launch
76
+
77
+ Without `BILLING_PROVIDER`, setting `FREE_DOMAINS` (e.g. `FREE_DOMAINS=10`) caps each account at that many domains, and the pricing page shows the free plan with pay as you go marked "coming soon". Raise one account's cap with `set-domain-limit`, which replaces the free plan's limit for that account. When billing is turned on, `FREE_DOMAINS` becomes the free threshold and must match the one in your payment provider.
78
+
75
79
  ## Billing: payment provider
76
80
 
77
81
  Payments go through **[Dodo Payments](https://dodopayments.com)** by default. Dodo is a *merchant of record*: it is the legal seller to your customers, so it charges them, handles VAT, GST and sales tax worldwide, and pays you out. It accepts individual developers, including in India, without a registered company. Stripe is also supported (`BILLING_PROVIDER=stripe`) for when you have a company that Stripe accepts. The two are interchangeable behind a small provider interface (`src/hosted/billing.ts`).
@@ -91,14 +95,15 @@ Do this in **test mode** first (Dodo dashboard toggle), then repeat in live mode
91
95
  Copy the product id (`pdt_…`).
92
96
  3. **Webhook.** Developer → Webhooks → add an endpoint at `https://<your PUBLIC_URL>/billing/webhook` subscribed to the `subscription.*` events. Copy its signing secret (`whsec_…`).
93
97
  4. **API key.** Developer → API keys → create one.
94
- 5. **Email for sign-in links**, sent through your own SendGrid account:
98
+ 5. **Email for sign-in links**, sent through your own Resend or SendGrid account:
95
99
  ```bash
96
100
  PUBLIC_URL=https://dns.yourapp.com # required for sign-up and billing
97
- EMAIL_FROM=login@yourapp.com # a verified sender in your SendGrid account
98
- MAIL_SENDGRID_API_KEY=SG.… # needs Mail Send permission
101
+ EMAIL_FROM=login@yourapp.com # on a domain verified in Resend (or a verified SendGrid sender)
102
+ MAIL_RESEND_API_KEY=re_… # a Resend key with sending access, or:
103
+ MAIL_SENDGRID_API_KEY=SG.… # a SendGrid key with Mail Send permission
99
104
  APP_NAME="Your Product"
100
105
  ```
101
- Without `EMAIL_FROM`/`MAIL_SENDGRID_API_KEY`, sign-in links are written to the log instead, which is handy in development.
106
+ If both keys are set, Resend is used. Without `EMAIL_FROM` and a key, sign-in links are written to the log instead, which is handy in development.
102
107
  6. **Run:**
103
108
  ```bash
104
109
  BILLING_PROVIDER=dodo \
@@ -143,6 +148,6 @@ With `BILLING_PROVIDER=stripe`, the same flow uses Stripe Checkout and Stripe's
143
148
 
144
149
  ## Limits and notes
145
150
 
146
- - **Soft caps:** two domain creations at the same instant can both pass a cap.
151
+ - **Caps across instances:** a cap is exact within one process, but two instances creating domains for the same account at the same instant can both pass it.
147
152
  - **Free accounts:** usage is metered for them too (visible in the dashboard), but only accounts with a payment method on file are reported to the provider.
148
153
  - **Not yet tested against a live Dodo account:** the integration follows Dodo's API reference and is tested against a simulated Dodo API. Before charging real customers, run one full checkout → usage → invoice cycle in test mode.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dkimkit",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "DKIMkit: authenticate sending domains with SendGrid, Postmark, Mailgun or Amazon SES. DNS records written or shown, propagation tracked, verified automatically.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",