dkimkit 0.0.0-stage → 0.6.1
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/README.md +153 -2
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +398 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/dns-host.d.ts +10 -0
- package/dist/src/dns-host.js +31 -0
- package/dist/src/dns-host.js.map +1 -0
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +51 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/hosted/api.d.ts +95 -0
- package/dist/src/hosted/api.js +781 -0
- package/dist/src/hosted/api.js.map +1 -0
- package/dist/src/hosted/auth.d.ts +70 -0
- package/dist/src/hosted/auth.js +121 -0
- package/dist/src/hosted/auth.js.map +1 -0
- package/dist/src/hosted/billing-dodo.d.ts +58 -0
- package/dist/src/hosted/billing-dodo.js +139 -0
- package/dist/src/hosted/billing-dodo.js.map +1 -0
- package/dist/src/hosted/billing-stripe.d.ts +82 -0
- package/dist/src/hosted/billing-stripe.js +205 -0
- package/dist/src/hosted/billing-stripe.js.map +1 -0
- package/dist/src/hosted/billing.d.ts +114 -0
- package/dist/src/hosted/billing.js +173 -0
- package/dist/src/hosted/billing.js.map +1 -0
- package/dist/src/hosted/cli.d.ts +2 -0
- package/dist/src/hosted/cli.js +305 -0
- package/dist/src/hosted/cli.js.map +1 -0
- package/dist/src/hosted/crypto.d.ts +22 -0
- package/dist/src/hosted/crypto.js +55 -0
- package/dist/src/hosted/crypto.js.map +1 -0
- package/dist/src/hosted/index.d.ts +7 -0
- package/dist/src/hosted/index.js +11 -0
- package/dist/src/hosted/index.js.map +1 -0
- package/dist/src/hosted/markdown.d.ts +16 -0
- package/dist/src/hosted/markdown.js +168 -0
- package/dist/src/hosted/markdown.js.map +1 -0
- package/dist/src/hosted/net.d.ts +9 -0
- package/dist/src/hosted/net.js +60 -0
- package/dist/src/hosted/net.js.map +1 -0
- package/dist/src/hosted/postgres-store.d.ts +62 -0
- package/dist/src/hosted/postgres-store.js +387 -0
- package/dist/src/hosted/postgres-store.js.map +1 -0
- package/dist/src/hosted/serialize.d.ts +144 -0
- package/dist/src/hosted/serialize.js +126 -0
- package/dist/src/hosted/serialize.js.map +1 -0
- package/dist/src/hosted/service.d.ts +166 -0
- package/dist/src/hosted/service.js +582 -0
- package/dist/src/hosted/service.js.map +1 -0
- package/dist/src/hosted/site.d.ts +54 -0
- package/dist/src/hosted/site.js +472 -0
- package/dist/src/hosted/site.js.map +1 -0
- package/dist/src/hosted/sqlite-store.d.ts +45 -0
- package/dist/src/hosted/sqlite-store.js +351 -0
- package/dist/src/hosted/sqlite-store.js.map +1 -0
- package/dist/src/hosted/store.d.ts +257 -0
- package/dist/src/hosted/store.js +181 -0
- package/dist/src/hosted/store.js.map +1 -0
- package/dist/src/hosted/widget.d.ts +46 -0
- package/dist/src/hosted/widget.js +130 -0
- package/dist/src/hosted/widget.js.map +1 -0
- package/dist/src/hosted/worker.d.ts +45 -0
- package/dist/src/hosted/worker.js +142 -0
- package/dist/src/hosted/worker.js.map +1 -0
- package/dist/src/http.d.ts +14 -0
- package/dist/src/http.js +54 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +19 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/orchestrator.d.ts +70 -0
- package/dist/src/orchestrator.js +69 -0
- package/dist/src/orchestrator.js.map +1 -0
- package/dist/src/propagation.d.ts +30 -0
- package/dist/src/propagation.js +76 -0
- package/dist/src/propagation.js.map +1 -0
- package/dist/src/providers/dns/aws.d.ts +28 -0
- package/dist/src/providers/dns/aws.js +103 -0
- package/dist/src/providers/dns/aws.js.map +1 -0
- package/dist/src/providers/dns/cloudflare.d.ts +26 -0
- package/dist/src/providers/dns/cloudflare.js +97 -0
- package/dist/src/providers/dns/cloudflare.js.map +1 -0
- package/dist/src/providers/dns/manual.d.ts +12 -0
- package/dist/src/providers/dns/manual.js +18 -0
- package/dist/src/providers/dns/manual.js.map +1 -0
- package/dist/src/providers/dns/route53.d.ts +34 -0
- package/dist/src/providers/dns/route53.js +176 -0
- package/dist/src/providers/dns/route53.js.map +1 -0
- package/dist/src/providers/email/factory.d.ts +29 -0
- package/dist/src/providers/email/factory.js +67 -0
- package/dist/src/providers/email/factory.js.map +1 -0
- package/dist/src/providers/email/mailgun.d.ts +36 -0
- package/dist/src/providers/email/mailgun.js +141 -0
- package/dist/src/providers/email/mailgun.js.map +1 -0
- package/dist/src/providers/email/postmark.d.ts +37 -0
- package/dist/src/providers/email/postmark.js +121 -0
- package/dist/src/providers/email/postmark.js.map +1 -0
- package/dist/src/providers/email/sendgrid.d.ts +31 -0
- package/dist/src/providers/email/sendgrid.js +109 -0
- package/dist/src/providers/email/sendgrid.js.map +1 -0
- package/dist/src/providers/email/ses.d.ts +39 -0
- package/dist/src/providers/email/ses.js +123 -0
- package/dist/src/providers/email/ses.js.map +1 -0
- package/dist/src/providers/factory.d.ts +14 -0
- package/dist/src/providers/factory.js +32 -0
- package/dist/src/providers/factory.js.map +1 -0
- package/dist/src/records.d.ts +35 -0
- package/dist/src/records.js +109 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/server.d.ts +67 -0
- package/dist/src/server.js +301 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/types.d.ts +75 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/xml.d.ts +6 -0
- package/dist/src/xml.js +23 -0
- package/dist/src/xml.js.map +1 -0
- package/docs/billing.md +43 -0
- package/docs/getting-started.md +57 -0
- package/docs/hosted-api.md +192 -0
- package/docs/self-hosting.md +148 -0
- package/docs/widget.md +121 -0
- package/package.json +63 -4
- package/web/dashboard.html +390 -0
- package/web/index.html +697 -0
- package/web/widget-frame.html +480 -0
- package/web/widget.js +91 -0
package/docs/billing.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Plans and billing
|
|
2
|
+
|
|
3
|
+
Start free, and pay only for what you use once you grow past the free allowance.
|
|
4
|
+
|
|
5
|
+
| | Free | Pay as you go |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Price | Free, no card needed | First {{FREE_DOMAINS}} active domains free, then **{{PRICE}} per active domain per month** |
|
|
8
|
+
| Domains | Up to {{FREE_DOMAINS}} | Unlimited |
|
|
9
|
+
| Features | API, embeddable widget, Cloudflare, Route 53 and manual DNS, webhooks, usage reports | Everything in Free |
|
|
10
|
+
|
|
11
|
+
## What counts as an active domain
|
|
12
|
+
|
|
13
|
+
A domain is **active** in a billing period if it was verified on at least one day of that period. Each domain counts once per period, however many days it was active.
|
|
14
|
+
|
|
15
|
+
- A domain verified on the 3rd and deleted on the 10th counts once that month.
|
|
16
|
+
- Deleting a domain doesn't remove it from a period it was already active in.
|
|
17
|
+
- Domains that never verify, for example because the DNS records were never added, aren't counted.
|
|
18
|
+
|
|
19
|
+
**Example:** you have 8 active domains this month. The first {{FREE_DOMAINS}} are free, so you pay for the rest, at {{PRICE}} each.
|
|
20
|
+
|
|
21
|
+
## Upgrading
|
|
22
|
+
|
|
23
|
+
Sign in to your [dashboard](/dashboard) and choose **Add payment method**. You'll go to our payment partner's secure checkout page. Once it's done, the domain cap is lifted immediately.
|
|
24
|
+
|
|
25
|
+
On the free plan, creating more than {{FREE_DOMAINS}} domains returns `402 plan_limit_reached` from the API, with an `upgrade_url` that points to your dashboard. Your existing domains keep working either way.
|
|
26
|
+
|
|
27
|
+
## Your bill
|
|
28
|
+
|
|
29
|
+
You're billed monthly for the active domains in the previous period. Your dashboard shows this period's active domains and the estimated amount so far, and the [usage API](/docs/hosted-api#usage-and-billing) gives the same numbers, broken down by your own customers if you like.
|
|
30
|
+
|
|
31
|
+
## Cancelling
|
|
32
|
+
|
|
33
|
+
Cancel any time from **Manage billing** in the dashboard. Your existing domains keep working, and you go back to the free plan's limit for new domains. You're billed only for domains active during the period before you cancelled.
|
|
34
|
+
|
|
35
|
+
## If a payment fails
|
|
36
|
+
|
|
37
|
+
We keep your plan active while the payment is retried, and the dashboard asks you to update your payment method. If the payment can't be collected, the subscription ends and the account returns to the free plan.
|
|
38
|
+
|
|
39
|
+
## Payments and tax
|
|
40
|
+
|
|
41
|
+
Payments are processed by [Dodo Payments](https://dodopayments.com), our merchant of record. Dodo is the seller on your invoice, charges your payment method, and handles VAT, GST and sales tax for your country. Your card details go to Dodo and never reach our servers. Invoices and payment methods are available from **Manage billing** in the dashboard.
|
|
42
|
+
|
|
43
|
+
Questions about a charge? See our [refund policy](/refunds) or [contact us](/contact).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
{{APP_NAME}} authenticates your customers' sending domains with SendGrid, Postmark, Mailgun or Amazon SES. It creates the DNS records, writes them for your customers or shows them what to add, keeps checking until your email provider verifies the domain, and tells your app when each domain is ready. This guide takes you from sign-up to your first verified domain.
|
|
4
|
+
|
|
5
|
+
## 1. Create an account
|
|
6
|
+
|
|
7
|
+
[Sign up](/dashboard) with your email address. We email you a link; there's no password.{{#billing}} You start on the free plan, which includes {{FREE_DOMAINS}} domains.{{/billing}}
|
|
8
|
+
|
|
9
|
+
## 2. Connect your email provider
|
|
10
|
+
|
|
11
|
+
In the dashboard under **Settings**, choose your email provider and enter its credentials:
|
|
12
|
+
|
|
13
|
+
| Provider | What to enter |
|
|
14
|
+
|---|---|
|
|
15
|
+
| SendGrid | An API key with full **Sender Authentication** access, plus **Inbound Parse** to receive email |
|
|
16
|
+
| Postmark | An **account** API token (Account → API Tokens), plus a server ID if you want to receive email |
|
|
17
|
+
| Mailgun | A private API key and your account's region (US or EU) |
|
|
18
|
+
| Amazon SES | An IAM access key with SES permissions (create, get and delete identities, set MAIL FROM) and your SES region |
|
|
19
|
+
|
|
20
|
+
Credentials are encrypted at rest and never shown again. Each account uses one provider; to switch, delete your domains first.
|
|
21
|
+
|
|
22
|
+
Each provider asks for slightly different DNS records, and we handle the differences. For example, if a domain already has an SPF record, we add the provider to it rather than creating a second SPF record, which would break SPF.
|
|
23
|
+
|
|
24
|
+
## 3. Create an API key
|
|
25
|
+
|
|
26
|
+
Choose **Create new API key** in the dashboard and copy it. It's shown once. Use it from your backend as a bearer token:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export API_KEY=dk_live_…
|
|
30
|
+
curl {{PUBLIC_URL}}/v1/account -H "Authorization: Bearer $API_KEY"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 4. Register a domain
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
curl -X POST {{PUBLIC_URL}}/v1/domains \
|
|
37
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
38
|
+
-H "Content-Type: application/json" \
|
|
39
|
+
-d '{"domain": "customer.com", "tenant_id": "cus_42", "dns": {"provider": "manual"}}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The response includes the DNS records your customer needs to add. Then you have two options:
|
|
43
|
+
|
|
44
|
+
- **Show the records:** show them in your app, so your customer can add them at their DNS host. We detect when they're live.
|
|
45
|
+
- **Write them for the customer:** pass `"dns": {"provider": "cloudflare", "cloudflare_api_token": "…"}`, or the Route 53 equivalent, and we add the records to their DNS ourselves.
|
|
46
|
+
|
|
47
|
+
Or skip building UI entirely and use the [embeddable widget](/docs/widget), which walks your customers through it, including connecting Cloudflare or Route 53.
|
|
48
|
+
|
|
49
|
+
## 5. Find out when it's verified
|
|
50
|
+
|
|
51
|
+
Check `GET /v1/domains/{id}`, or better, set a **Webhook URL** in the dashboard and we'll POST a signed `domain.verified` event as soon as your email provider confirms the domain. We keep checking in the background, and re-check verified domains daily so you hear about it if a record is later removed.
|
|
52
|
+
|
|
53
|
+
## Next steps
|
|
54
|
+
|
|
55
|
+
- [API reference](/docs/hosted-api): every endpoint, the domain lifecycle, errors and webhooks
|
|
56
|
+
- [Embeddable widget](/docs/widget): let your customers connect their own DNS inside your app
|
|
57
|
+
{{#billing}}- [Plans and billing](/docs/billing): pricing and what counts as an active domain{{/billing}}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
A multi-tenant REST API for SaaS products whose customers send email from their own domains. Your backend registers a customer's domain. The API returns the DNS records to show that customer, or writes them through the customer's DNS provider. It then keeps checking until SendGrid verifies the domain, and calls your webhook when the status changes.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Your app ──POST /v1/domains──▶ API ──▶ SendGrid / Postmark / Mailgun / Amazon SES
|
|
7
|
+
▲ │
|
|
8
|
+
└──── signed webhooks ◀── worker ──▶ Cloudflare / Route 53 (tenant's DNS) + DNS polling
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Authentication and errors
|
|
12
|
+
|
|
13
|
+
Send `Authorization: Bearer dk_live_…`. Each account is limited to 10 requests/second, with bursts up to 30. Over the limit you get `429` and a `Retry-After` header.
|
|
14
|
+
|
|
15
|
+
Errors always have this shape:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{ "error": { "type": "invalid_request", "message": "domain must be a valid domain name, like example.com.", "request_id": "…" } }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Status | `type` | When |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| 400 | `invalid_request` | Validation failed. Unknown fields are rejected, so typos surface |
|
|
24
|
+
| 401 | `unauthorized` | Missing or wrong API key |
|
|
25
|
+
| 404 | `not_found` | Not found, or it belongs to another account |
|
|
26
|
+
| 402 | `plan_limit_reached` | The account is at its `domain_limit`; the error includes `domain_limit` |
|
|
27
|
+
| 409 | `conflict` | The domain is already registered; the error includes `existing_id` |
|
|
28
|
+
| 429 | `rate_limited` | Slow down |
|
|
29
|
+
| 502 | `provider_error` | The email provider, Cloudflare or AWS rejected the call; includes `provider`, `provider_status` and a `hint` |
|
|
30
|
+
|
|
31
|
+
## Domains
|
|
32
|
+
|
|
33
|
+
### `POST /v1/domains`
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"domain": "customer.com",
|
|
38
|
+
"tenant_id": "your-customer-id",
|
|
39
|
+
"metadata": { "plan": "pro" },
|
|
40
|
+
"subdomain": "em",
|
|
41
|
+
"sendgrid_subuser": "customer-sub",
|
|
42
|
+
"inbound": { "url": "https://api.yourapp.com/inbound", "hostname": "parse.customer.com", "spam_check": false, "send_raw": false },
|
|
43
|
+
"dns": { "provider": "cloudflare", "cloudflare_api_token": "…" },
|
|
44
|
+
"overwrite": false
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Only `domain` is required.
|
|
49
|
+
- **`dns.provider`:** one of
|
|
50
|
+
- `manual` (default): you show the records to your customer.
|
|
51
|
+
- `cloudflare`: needs `cloudflare_api_token`.
|
|
52
|
+
- `route53`: needs `aws_access_key_id` and `aws_secret_access_key`. `aws_session_token` and `route53_hosted_zone_id` are optional.
|
|
53
|
+
- **Credentials:** they are encrypted at rest and never returned. The API never falls back to the server's own credentials.
|
|
54
|
+
- **`sendgrid_subuser`:** SendGrid accounts only: runs every SendGrid call for this domain as that subuser.
|
|
55
|
+
- **`subdomain`:** the return-path / MAIL FROM label (SendGrid picks one by default, Postmark uses `pm-bounces`, Amazon SES `mail`; Mailgun doesn't use it).
|
|
56
|
+
- **`inbound.hostname`:** defaults to `parse.<domain>`, except with Mailgun, which only receives on domains added to it, so the default is the domain itself (send from a subdomain such as `mg.customer.com`). Postmark receives one inbound domain per Postmark server. Amazon SES doesn't support inbound here yet.
|
|
57
|
+
- **`overwrite`:** allows replacing existing CNAME/MX records that point elsewhere.
|
|
58
|
+
|
|
59
|
+
The domain is created at the email provider during the request, so the `201` response already contains the records. Records may carry a `note`, for example advice to merge an SPF record into an existing one:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"id": "dom_…", "object": "domain", "domain": "customer.com", "status": "pending",
|
|
64
|
+
"records": [
|
|
65
|
+
{ "type": "CNAME", "name": "em1234.customer.com", "value": "u1234.wl.sendgrid.net", "purpose": "mail_cname", "status": "pending" },
|
|
66
|
+
{ "type": "CNAME", "name": "s1._domainkey.customer.com", "value": "s1.domainkey.u1234.wl.sendgrid.net", "purpose": "dkim1", "status": "pending" },
|
|
67
|
+
{ "type": "MX", "name": "parse.customer.com", "value": "mx.sendgrid.net", "priority": 10, "purpose": "inbound_mx", "status": "pending" }
|
|
68
|
+
],
|
|
69
|
+
"dns": { "provider": "cloudflare", "credentials_set": true },
|
|
70
|
+
"validation": null, "conflicts": null, "last_error": null,
|
|
71
|
+
"next_check_at": "…", "verified_at": null, "expires_at": "…", "created_at": "…", "updated_at": "…"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Other endpoints
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `GET /v1/domains?tenant_id=&status=&limit=&starting_after=` | Newest first. Responses carry `has_more` and `next_cursor` |
|
|
80
|
+
| `GET /v1/domains/:id` | |
|
|
81
|
+
| `PATCH /v1/domains/:id` | `tenant_id`, `metadata`, `inbound` (`null` removes it), `dns`, `overwrite`. Changes to DNS, inbound or overwrite restart checks |
|
|
82
|
+
| `POST /v1/domains/:id/verify` | Check now. Also restarts a `failed` or `conflict` domain. Returns `202` |
|
|
83
|
+
| `DELETE /v1/domains/:id?remote=true` | Remove the domain. With `remote=true`, also delete the domain and its inbound route at the email provider. Never deletes DNS records |
|
|
84
|
+
| `GET /v1/domains/:id/events` | History of what happened to this domain |
|
|
85
|
+
| `GET /v1/account`, `PATCH /v1/account` | `name`, `webhook_url`, `widget_origins`, and `email_provider` (credentials are write-only; see below) |
|
|
86
|
+
| `GET /v1/usage?from=&to=&group_by=tenant` | Usage for billing; see [Usage and billing](#usage-and-billing) |
|
|
87
|
+
| `POST /v1/account/rotate-api-key` | Returns a new key and invalidates the old one immediately |
|
|
88
|
+
| `POST /v1/account/rotate-webhook-secret` | |
|
|
89
|
+
| `PATCH /v1/account` `widget_origins` | Origins allowed to embed the widget |
|
|
90
|
+
| `POST /v1/widget-sessions` | Mint a browser session for the [embeddable widget](widget.md): `{ domain_id }` or `{ tenant_id, … }` |
|
|
91
|
+
|
|
92
|
+
### Email provider
|
|
93
|
+
|
|
94
|
+
Each account uses one email provider. Set it, and its credentials, with `PATCH /v1/account`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "email_provider": { "provider": "postmark", "account_token": "…", "inbound_server_id": "123" } }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| `provider` | Fields |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `sendgrid` | `api_key` (`sendgrid_api_key` at the top level also works) |
|
|
103
|
+
| `postmark` | `account_token`; `inbound_server_id` to receive email |
|
|
104
|
+
| `mailgun` | `api_key`, `region` (`us` or `eu`, default `us`) |
|
|
105
|
+
| `ses` | `access_key_id`, `secret_access_key`, optional `session_token`, `region` |
|
|
106
|
+
|
|
107
|
+
Fields you leave out keep their saved values, so you can rotate one key at a time. Credentials are encrypted and never returned; `GET /v1/account` shows `email_provider` and `email_credentials_set`. To switch provider, delete the account's domains first, since each domain lives at the provider that created it.
|
|
108
|
+
|
|
109
|
+
### Lifecycle
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
pending ─▶ awaiting_dns ─▶ verifying ─▶ verified ──(daily re-check fails)──▶ awaiting_dns
|
|
113
|
+
│ │ │
|
|
114
|
+
└────────────┴──────────────┴──▶ conflict (existing records point elsewhere; nothing written)
|
|
115
|
+
└──▶ failed (bad credentials, missing zone, or not verified within 72h)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- **Checking:** the worker checks at 30s, 1m, 2m, 5m, 10m, 15m, then every 30 minutes. Each check writes DNS if needed, looks for the records on the domain's authoritative nameservers, then asks SendGrid to validate.
|
|
119
|
+
- **Verified domains:** these are re-checked every 24 hours.
|
|
120
|
+
- **Credential and config problems:** these fail immediately with a hint in `last_error`. Network errors and 5xx responses are retried.
|
|
121
|
+
- **Restarting:** fix the cause, then call `verify`, or `PATCH` the domain.
|
|
122
|
+
|
|
123
|
+
## Usage and billing
|
|
124
|
+
|
|
125
|
+
Usage is metered automatically. Each billable fact is stored once, under an id derived from what it is (for example `active_domain_day:dom_1:2026-10-25`). Recording it again, or several workers recording it at once, can't double-count. Usage rows keep a snapshot of the domain name and `tenant_id`, so deleting a domain doesn't erase its billing history.
|
|
126
|
+
|
|
127
|
+
| Metric | Meaning |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `active_domains` | Distinct domains that were verified on at least one day in the period. The usual number to bill on, like seats |
|
|
130
|
+
| `active_domain_days` | The sum over days of verified domains, for prorated billing (e.g. bill `active_domain_days / days in month`) |
|
|
131
|
+
| `domains_created` | Domains registered in the period |
|
|
132
|
+
| `verifications` | Times a domain became verified, including after a lost verification |
|
|
133
|
+
|
|
134
|
+
A domain counts as active on a day when it was verified at some point that day. That's recorded the moment it verifies, at each daily re-check, and by an hourly pass over all verified domains. Days are UTC.
|
|
135
|
+
|
|
136
|
+
`GET /v1/usage` lets an account see its own usage. It defaults to the current month so far; `from`/`to` are inclusive `YYYY-MM-DD` dates, up to 366 days. Add `group_by=tenant` to break it down by your customers, which is useful if you pass the cost on to them:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"object": "usage", "from": "2026-10-01", "to": "2026-10-25",
|
|
141
|
+
"active_domains": 3, "active_domain_days": 41, "domains_created": 4, "verifications": 3,
|
|
142
|
+
"current_domains": 4, "domain_limit": 25,
|
|
143
|
+
"by_tenant": [
|
|
144
|
+
{ "tenant_id": "cus_a", "active_domains": 2, "active_domain_days": 30, "domains_created": 2, "verifications": 2 },
|
|
145
|
+
{ "tenant_id": "cus_b", "active_domains": 1, "active_domain_days": 11, "domains_created": 2, "verifications": 1 }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Plans can cap how many domains an account has at once (the free plan allows 3). Over the cap, `POST /v1/domains` returns `402 plan_limit_reached` with an `upgrade_url`. In the widget, end users see a neutral "contact support" message instead.
|
|
151
|
+
|
|
152
|
+
## Webhooks
|
|
153
|
+
|
|
154
|
+
Set `webhook_url` on the account (it must be public `https`). Each event is POSTed as JSON with a snapshot of the domain:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{ "id": "evt_…", "object": "event", "type": "domain.verified", "created_at": "…", "data": { "domain": { "id": "dom_…", "status": "verified", … } } }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Event types:**
|
|
161
|
+
- `domain.created`
|
|
162
|
+
- `domain.dns_written`
|
|
163
|
+
- `domain.verified`
|
|
164
|
+
- `domain.verification_lost`
|
|
165
|
+
- `domain.conflict`
|
|
166
|
+
- `domain.failed` (with `data.reason`)
|
|
167
|
+
- `domain.deleted`
|
|
168
|
+
|
|
169
|
+
**Delivery:** a non-2xx response or a timeout (10s) is retried after 10s, 1m, 5m, 30m, 2h, 6h, 12h and 24h. Delivery is at least once and can arrive out of order. Deduplicate on `id` (also sent as `X-DKIMkit-Event-Id`), and trust `data.domain.updated_at` rather than arrival order.
|
|
170
|
+
|
|
171
|
+
Verify each request with the raw body and the account's `webhook_secret`:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { verifyWebhookSignature } from 'dkimkit';
|
|
175
|
+
|
|
176
|
+
app.post('/webhooks/dkimkit', express.raw({ type: 'application/json' }), (req, res) => {
|
|
177
|
+
if (!verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, req.body.toString(), req.header('x-dkimkit-signature'))) {
|
|
178
|
+
return res.sendStatus(400);
|
|
179
|
+
}
|
|
180
|
+
const event = JSON.parse(req.body.toString());
|
|
181
|
+
// …
|
|
182
|
+
res.sendStatus(200);
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The signature header is `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`. Requests older than 5 minutes are rejected as replays.
|
|
187
|
+
|
|
188
|
+
## Security notes
|
|
189
|
+
|
|
190
|
+
- **Secrets at rest:** API keys are stored as SHA-256 hashes. Email provider credentials and DNS credentials are encrypted with AES-256-GCM using random IVs and authentication tags. None of them are ever returned by the API.
|
|
191
|
+
- **Tenant isolation:** every query is scoped to the authenticated account, and other accounts' ids return `404`.
|
|
192
|
+
- **Webhook targets:** URLs are checked when saved and again before every delivery. They must be https and must not resolve to loopback, private, link-local, CGNAT or multicast addresses. Redirects are not followed.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Self-hosting and operations
|
|
2
|
+
|
|
3
|
+
For whoever runs the service. This file is not published on the website.
|
|
4
|
+
|
|
5
|
+
## Running the server
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
export ENCRYPTION_KEY=$(npx dkimkit-api generate-encryption-key) # keep this safe; it decrypts stored credentials
|
|
9
|
+
export DATABASE_URL=postgres://user:pass@db.internal/dkimkit # or DATABASE_PATH=./data/dkimkit.db for SQLite
|
|
10
|
+
|
|
11
|
+
SENDGRID_API_KEY=SG.xxx npx dkimkit-api create-account --name "Acme" --with-sendgrid-key
|
|
12
|
+
# Account: acct_…
|
|
13
|
+
# API key: dk_live_… (shown once; only a hash is stored)
|
|
14
|
+
|
|
15
|
+
npx dkimkit-api serve # :8080, API and background worker in one process
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Docker:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
docker build -t dkimkit-api .
|
|
22
|
+
docker run -p 8080:8080 -v dkimkit-data:/data -e ENCRYPTION_KEY=… dkimkit-api
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Variable | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `ENCRYPTION_KEY` | Required. 32 bytes, base64 or hex. AES-256-GCM key for stored SendGrid keys and DNS credentials |
|
|
28
|
+
| `DATABASE_URL` | `postgres://…` connection string. Use this in production |
|
|
29
|
+
| `DATABASE_PATH` | Used when `DATABASE_URL` is unset: SQLite file, default `./data/dkimkit.db` |
|
|
30
|
+
| `PORT`, `HOST` | Default `8080`, `0.0.0.0` |
|
|
31
|
+
| `WORKER_CONCURRENCY` | Domains checked in parallel, default 4 |
|
|
32
|
+
| `RUN_WORKERS=false` | Serve the API only (run workers in another process on the same database) |
|
|
33
|
+
| `ALLOW_PRIVATE_WEBHOOKS=true` | Development only: allow `http://` and private-network webhook URLs |
|
|
34
|
+
| `PUBLIC_URL` | Public base URL (e.g. `https://dns.example.com`) used in widget links. Defaults to the request's host |
|
|
35
|
+
|
|
36
|
+
## Storage
|
|
37
|
+
|
|
38
|
+
| | SQLite (`DATABASE_PATH`) | Postgres (`DATABASE_URL`) |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Good for | One server, development | Production, several API or worker instances |
|
|
41
|
+
| Needs | Node 22.13+ (`node:sqlite`) | The `pg` package (an optional dependency, included in the Docker image) |
|
|
42
|
+
|
|
43
|
+
Both create and upgrade their schema on startup, and that's safe when several instances start at once (Postgres uses an advisory lock). With Postgres you can run any number of API instances and workers against one database. Workers claim due jobs with `FOR UPDATE SKIP LOCKED`, so no domain is checked twice. A common setup is API instances with `RUN_WORKERS=false` behind a load balancer, plus one or two dedicated worker instances.
|
|
44
|
+
|
|
45
|
+
The schema keeps the same shape on both backends: JSON columns are `TEXT` in SQLite and `JSONB` in Postgres, and timestamps are millisecond integers (`BIGINT` in Postgres). The column mapping lives in one place, `src/hosted/store.ts`. To add another backend, implement the `Store` interface there and run `test/store.test.ts` against it.
|
|
46
|
+
|
|
47
|
+
## Accounts, limits and usage reports
|
|
48
|
+
|
|
49
|
+
Invoice from the report for all accounts, and cap plans with a domain limit:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx dkimkit-api usage-report --from 2026-10-01 --to 2026-10-31 # CSV, one row per account
|
|
53
|
+
npx dkimkit-api usage-report --format json
|
|
54
|
+
npx dkimkit-api set-domain-limit --account acct_… --limit 25 # or --unlimited
|
|
55
|
+
SENDGRID_API_KEY=SG.xxx npx dkimkit-api create-account --name "Acme" --with-sendgrid-key # operator-created account
|
|
56
|
+
```
|
|
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.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
## Website and legal pages
|
|
62
|
+
|
|
63
|
+
The service serves a public site when `PUBLIC_URL` is set: a landing page at `/`, `/pricing`, `/docs`, `/about`, `/contact`, `/terms`, `/privacy` and `/refunds`. Set these so the pages show the right details:
|
|
64
|
+
|
|
65
|
+
| Variable | Used for |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `APP_NAME` | Product name everywhere (default "DKIMkit") |
|
|
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 |
|
|
70
|
+
| `LEGAL_COUNTRY` | Country whose law governs the terms (e.g. "India") |
|
|
71
|
+
| `LEGAL_EFFECTIVE_DATE` | "Last updated" date on the legal pages (default: the build's date) |
|
|
72
|
+
|
|
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
|
+
|
|
75
|
+
## Billing: payment provider
|
|
76
|
+
|
|
77
|
+
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`).
|
|
78
|
+
|
|
79
|
+
## Billing setup (Dodo Payments)
|
|
80
|
+
|
|
81
|
+
Do this in **test mode** first (Dodo dashboard toggle), then repeat in live mode.
|
|
82
|
+
|
|
83
|
+
1. **Meter.** In the Dodo dashboard, open Products → Meters and create:
|
|
84
|
+
- Event name: `active_domains` (must match `DODO_METER_EVENT`, default `active_domains`)
|
|
85
|
+
- Aggregation: **Max**, over property `active_domains`
|
|
86
|
+
- Measurement unit: `domains`
|
|
87
|
+
2. **Product.** Create a subscription product with pricing type **Usage Based Billing**, billed monthly. Attach the meter with:
|
|
88
|
+
- Price per unit: **$2** (match `PRICE_PER_DOMAIN_CENTS`, default 200)
|
|
89
|
+
- Free threshold: **3** (match `FREE_DOMAINS`, default 3)
|
|
90
|
+
|
|
91
|
+
Copy the product id (`pdt_…`).
|
|
92
|
+
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
|
+
4. **API key.** Developer → API keys → create one.
|
|
94
|
+
5. **Email for sign-in links**, sent through your own SendGrid account:
|
|
95
|
+
```bash
|
|
96
|
+
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
|
|
99
|
+
APP_NAME="Your Product"
|
|
100
|
+
```
|
|
101
|
+
Without `EMAIL_FROM`/`MAIL_SENDGRID_API_KEY`, sign-in links are written to the log instead, which is handy in development.
|
|
102
|
+
6. **Run:**
|
|
103
|
+
```bash
|
|
104
|
+
BILLING_PROVIDER=dodo \
|
|
105
|
+
DODO_MODE=test \
|
|
106
|
+
DODO_API_KEY=… \
|
|
107
|
+
DODO_WEBHOOK_SECRET=whsec_… \
|
|
108
|
+
DODO_PRODUCT_ID=pdt_… \
|
|
109
|
+
npx dkimkit-api serve
|
|
110
|
+
```
|
|
111
|
+
The startup log line shows `"billing": "dodo (test mode)"`. Switch to `DODO_MODE=live`, with live-mode keys, product and webhook secret, when you're ready to charge.
|
|
112
|
+
|
|
113
|
+
`FREE_DOMAINS` and `PRICE_PER_DOMAIN_CENTS` only drive the dashboard's limits and estimates. What's charged is the product's price and free threshold in Dodo, so keep them in sync.
|
|
114
|
+
|
|
115
|
+
## How usage reaches Dodo
|
|
116
|
+
|
|
117
|
+
- **Hourly reporting:** every hour, the worker records an active day for each verified domain, then counts each paying account's distinct active domains in its current billing period. The period comes from the subscription's `previous_billing_date` and `next_billing_date` in Dodo's webhooks.
|
|
118
|
+
- **Running totals:** when that count has grown, it sends one usage event to `POST /events/ingest` with the **running total** (`metadata.active_domains`). The meter's **Max** aggregation bills the period's highest total, and Dodo's free threshold makes the first 3 free. The count never decreases within a period, so its maximum is exactly the number of distinct active domains.
|
|
119
|
+
- **No double billing:**
|
|
120
|
+
- Each event's `event_id` is `account:periodStart:total`, which Dodo treats as an idempotency key. Re-sending the same total is harmless.
|
|
121
|
+
- The total already reported is stored per account and period, so unchanged totals aren't re-sent.
|
|
122
|
+
- A renewal starts a new period, which is reported from zero.
|
|
123
|
+
- **Timing:** Dodo accepts usage events up to 1 hour old. Events are timestamped when sent, so the hourly report always qualifies.
|
|
124
|
+
|
|
125
|
+
## Webhook handling
|
|
126
|
+
|
|
127
|
+
Dodo signs webhooks with the Standard Webhooks scheme. Each request is checked as follows:
|
|
128
|
+
- **Signature:** an HMAC-SHA256 over `webhook-id.webhook-timestamp.body`, keyed with the base64 secret after its `whsec_` prefix, compared in constant time. Several `v1,` signatures are accepted during secret rotation.
|
|
129
|
+
- **Freshness:** the timestamp must be within 5 minutes.
|
|
130
|
+
- **Processed once:** each `webhook-id` is processed only once, since Dodo retries with the same id.
|
|
131
|
+
- **Out-of-order events:** events older than the last one applied to an account are ignored, because delivery order isn't guaranteed. A cancellation for a subscription that has already been replaced doesn't downgrade the new one.
|
|
132
|
+
|
|
133
|
+
| Dodo subscription status | Account |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `active` | Pay as you go |
|
|
136
|
+
| `past_due`, `on_hold` | Pay as you go, payment issue |
|
|
137
|
+
| `cancelled`, `expired`, `failed`, `paused` | Free |
|
|
138
|
+
| `pending` | Unchanged until it settles |
|
|
139
|
+
|
|
140
|
+
## Stripe (alternative)
|
|
141
|
+
|
|
142
|
+
With `BILLING_PROVIDER=stripe`, the same flow uses Stripe Checkout and Stripe's Customer Portal, and reports usage to a Stripe billing meter. Stripe's meter sums events, so this provider reports increments rather than running totals. `npx dkimkit-api stripe-setup` creates the meter, a graduated metered price (first 3 free) and the webhook endpoint. Set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` and `STRIPE_PRICE_ID`.
|
|
143
|
+
|
|
144
|
+
## Limits and notes
|
|
145
|
+
|
|
146
|
+
- **Soft caps:** two domain creations at the same instant can both pass a cap.
|
|
147
|
+
- **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
|
+
- **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/docs/widget.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Embeddable widget
|
|
2
|
+
|
|
3
|
+
A drop-in UI that lets your users connect their own domain for sending email from inside your app. It runs on the [hosted API](hosted-api.md). The widget:
|
|
4
|
+
- asks for the domain, or uses one you already registered;
|
|
5
|
+
- detects where the domain's DNS is hosted;
|
|
6
|
+
- either connects Cloudflare or Route 53 and writes the records automatically, or shows the exact records to paste at any other provider;
|
|
7
|
+
- tracks progress until the domain is verified.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Your backend ── POST /v1/widget-sessions (secret key) ──▶ API ──▶ client_secret (wss_…, 1 hour)
|
|
11
|
+
Your frontend ── <script src=".../widget.js"> + DKIMkit.mount(el, { session }) ──▶ iframe on the API's origin
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 1. Allow your app's origin (once)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
curl -X PATCH https://dns.example.com/v1/account \
|
|
18
|
+
-H "Authorization: Bearer $DKIMKIT_API_KEY" -H "Content-Type: application/json" \
|
|
19
|
+
-d '{"widget_origins": ["https://app.yourproduct.com", "http://localhost:3000"]}'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Only these origins can embed the widget. `http://` is accepted only for `localhost`.
|
|
23
|
+
|
|
24
|
+
## 2. Create a session on your backend
|
|
25
|
+
|
|
26
|
+
Never send your API key to the browser. Create a short-lived session for the signed-in user instead:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// POST /api/email-domain/session (your route; requires the user to be signed in)
|
|
30
|
+
const res = await fetch('https://dns.example.com/v1/widget-sessions', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { Authorization: `Bearer ${process.env.DKIMKIT_API_KEY}`, 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
tenant_id: currentUser.workspaceId, // the user enters their domain in the widget
|
|
35
|
+
inbound_url: 'https://api.yourproduct.com/inbound-email', // optional
|
|
36
|
+
metadata: { plan: currentUser.plan }, // optional, never shown to the user
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
const { client_secret } = await res.json();
|
|
40
|
+
return { session: client_secret };
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
There are two ways to create a session:
|
|
44
|
+
- **`{ tenant_id, … }`:** the user types their domain in the widget. The domain is created with `tenant_id`, `metadata`, `subdomain`, `sendgrid_subuser` and `inbound_url` (its hostname becomes `parse.<domain>`). If the same tenant comes back with the same domain, the widget resumes it. If another tenant already registered that domain, the widget refuses without revealing anything about it.
|
|
45
|
+
- **`{ domain_id }`:** for a domain you already created with `POST /v1/domains`.
|
|
46
|
+
|
|
47
|
+
Sessions last 1 hour by default (`expires_in`: 60 to 86400 seconds). Each session is tied to one domain and can't read or change anything else.
|
|
48
|
+
|
|
49
|
+
## 3. Mount it
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<div id="email-domain"></div>
|
|
53
|
+
<script src="https://dns.example.com/widget.js"></script>
|
|
54
|
+
<script>
|
|
55
|
+
const { session } = await fetch('/api/email-domain/session', { method: 'POST' }).then((r) => r.json());
|
|
56
|
+
DKIMkit.mount('#email-domain', {
|
|
57
|
+
session,
|
|
58
|
+
onReady: (domain) => {}, // domain is null until the user enters one
|
|
59
|
+
onStatusChange: (domain) => {}, // pending → awaiting_dns → verifying → verified, or conflict / failed
|
|
60
|
+
onVerified: (domain) => showToast(`${domain.domain} is ready`),
|
|
61
|
+
onError: (error) => {}, // { type: 'session_expired', message }
|
|
62
|
+
appearance: { accent: '#4f46e5', mode: 'light', radius: 8 },
|
|
63
|
+
});
|
|
64
|
+
</script>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
React:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
function EmailDomainSetup({ session }: { session: string }) {
|
|
71
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const widget = window.DKIMkit.mount(ref.current!, { session, onVerified: () => refetch() });
|
|
74
|
+
return () => widget.unmount();
|
|
75
|
+
}, [session]);
|
|
76
|
+
return <div ref={ref} />;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`mount` returns `{ iframe, unmount }`. The iframe resizes itself to fit its content.
|
|
81
|
+
|
|
82
|
+
**Appearance:**
|
|
83
|
+
- `accent`: `#rrggbb`. Button text switches between black and white for contrast.
|
|
84
|
+
- `mode`: `light` (default), `dark`, or `auto`. `auto` follows the operating system; light is the default because matching your page matters more than the OS setting.
|
|
85
|
+
- `radius`: 0–24.
|
|
86
|
+
|
|
87
|
+
If you load `widget.js` some other way (a bundler or a module import), pass `apiOrigin: 'https://dns.example.com'`.
|
|
88
|
+
|
|
89
|
+
**Caching:** `widget.js` is served with a 5-minute cache, so updates to the loader take up to 5 minutes to reach browsers.
|
|
90
|
+
|
|
91
|
+
## What your users see
|
|
92
|
+
|
|
93
|
+
1. **Domain:** only for tenant sessions.
|
|
94
|
+
2. **Setup method:** chosen based on where the domain's DNS is hosted, which the widget detects from its nameservers.
|
|
95
|
+
- **Cloudflare or Route 53:** "Your DNS is hosted at Cloudflare. Connect it." The user creates a scoped token, or an IAM key with a minimal policy. The API checks the credentials against the zone before saving them, so a wrong token gets a clear error immediately.
|
|
96
|
+
- **GoDaddy, Namecheap and other recognized hosts:** "Add the records in your GoDaddy account." The records are shown with host names in the form registrars expect (`em1234`, `s1._domainkey`, `@`), each with a copy button.
|
|
97
|
+
3. **Progress:** each record shows *Waiting* or *Found*, with a "Check now" button. The user can close the page while checks continue in the background, and your webhooks still fire.
|
|
98
|
+
4. **Conflicts:** if existing records with the same names point elsewhere, the user sees which ones and can choose "Replace them". Records with other names are never touched.
|
|
99
|
+
5. **Done:** the domain is verified.
|
|
100
|
+
|
|
101
|
+
## Security model
|
|
102
|
+
|
|
103
|
+
- **Credential isolation.** The widget is an iframe served from the API's origin. The Cloudflare tokens and AWS keys your users type go straight to the API, encrypted at rest. They never pass through your page's JavaScript or your servers.
|
|
104
|
+
- **Session tokens, not API keys, in the browser.** `wss_…` tokens are stored as hashes, expire, cover one domain, and are rejected by the account API. The reverse holds too: your API key doesn't work on widget endpoints.
|
|
105
|
+
- **Clickjacking.** The frame sends `Content-Security-Policy: frame-ancestors <your widget_origins>`, so other sites can't embed it to trick users into typing credentials. An expired or invalid session sends `frame-ancestors 'none'`.
|
|
106
|
+
- **Locked-down frame.** `default-src 'none'`, a per-response nonce on the script, `connect-src 'self'`, no referrer, and theme parameters validated before they reach the page.
|
|
107
|
+
- **Messaging.** The frame only exchanges `postMessage` with a parent whose origin is in `widget_origins`, and the loader only accepts messages from its own iframe on the API origin. Messages carry the end-user view of the domain: no ids, metadata, webhook URLs or credentials.
|
|
108
|
+
- **End-user errors are sanitized.** Email-provider and configuration failures, which are yours to fix, appear to users as a generic message. The full error is in `last_error` on the API and in your `domain.failed` webhook.
|
|
109
|
+
|
|
110
|
+
## Widget endpoints
|
|
111
|
+
|
|
112
|
+
These are called by the iframe with `Authorization: Bearer wss_…`. You normally don't call them yourself.
|
|
113
|
+
|
|
114
|
+
| | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `GET /v1/widget/session` | Session state: `needs_domain`, `domain` (end-user view), `dns_host` |
|
|
117
|
+
| `POST /v1/widget/domain` | `{ domain }`, for tenant sessions |
|
|
118
|
+
| `POST /v1/widget/connect` | `{ provider: 'cloudflare' \| 'route53' \| 'manual', … }`. Credentials are verified before they're saved |
|
|
119
|
+
| `POST /v1/widget/verify` | Check now |
|
|
120
|
+
| `POST /v1/widget/resolve-conflicts` | The user approves replacing conflicting records |
|
|
121
|
+
| `GET /widget.js`, `GET /widget/frame` | The loader and the iframe page |
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dkimkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "DKIMkit: authenticate sending domains with SendGrid, Postmark, Mailgun or Amazon SES. DNS records written or shown, propagation tracked, verified automatically.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/src/index.js",
|
|
7
|
+
"types": "dist/src/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dkimkit": "dist/src/cli.js",
|
|
10
|
+
"dkimkit-api": "dist/src/hosted/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/src",
|
|
14
|
+
"web",
|
|
15
|
+
"docs"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "tsc && node --test \"dist/test/**/*.test.js\"",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"cloudflare",
|
|
27
|
+
"dkim",
|
|
28
|
+
"dns",
|
|
29
|
+
"domain-authentication",
|
|
30
|
+
"email",
|
|
31
|
+
"inbound-parse",
|
|
32
|
+
"mailgun",
|
|
33
|
+
"postmark",
|
|
34
|
+
"sendgrid",
|
|
35
|
+
"ses",
|
|
36
|
+
"spf"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.20.5",
|
|
41
|
+
"@types/pg": "^8.23.1",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
},
|
|
44
|
+
"exports": {
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./dist/src/index.d.ts",
|
|
47
|
+
"default": "./dist/src/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./hosted": {
|
|
50
|
+
"types": "./dist/src/hosted/index.d.ts",
|
|
51
|
+
"default": "./dist/src/hosted/index.js"
|
|
52
|
+
},
|
|
53
|
+
"./hosted/sqlite": {
|
|
54
|
+
"types": "./dist/src/hosted/sqlite-store.d.ts",
|
|
55
|
+
"default": "./dist/src/hosted/sqlite-store.js"
|
|
56
|
+
},
|
|
57
|
+
"./hosted/postgres": {
|
|
58
|
+
"types": "./dist/src/hosted/postgres-store.d.ts",
|
|
59
|
+
"default": "./dist/src/hosted/postgres-store.js"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"optionalDependencies": {
|
|
63
|
+
"pg": "^8.23.1"
|
|
64
|
+
}
|
|
65
|
+
}
|