DKIMkit

Node.js library and CLI

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.

It runs entirely on your machine with your own provider keys. It doesn't call DKIMkit'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 instead.

Library and CLIHosted API
RunsIn your processOn DKIMkit
DomainsNo limitSet by your plan
Waits for DNS and verificationWhile your code runsIn the background, then sends a webhook
Provider keysStay with youStored encrypted
UI for your customersBuild your ownEmbeddable widget

Install

npm install dkimkit

Requires Node 20 or later. The package has no runtime dependencies and ships its own TypeScript types.

Quick start

import { setupDomain, createEmailProvider, createDnsProvider } from 'dkimkit';

const email = createEmailProvider('sendgrid', { apiKey: process.env.SENDGRID_API_KEY });
const dns = createDnsProvider('cloudflare', { cloudflareApiToken: process.env.CLOUDFLARE_API_TOKEN });

const result = await setupDomain(email, dns, {
  domain: 'example.com',
  onEvent: (e) => console.log(e.type),
});

if (result.status === 'complete') console.log('example.com is authenticated');
else console.log('Not finished yet:', result.status);

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.

setupDomain(email, dns, options)

Runs the full flow and resolves with a result once the domain is verified, or when it can't go further yet.

  1. Domain. Reuses the provider's existing authentication for the domain, or creates one.
  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.
  3. Apply. Writes the new and changed records.
  4. Propagation. Polls the domain's authoritative nameservers until every record is visible.
  5. Validation. Asks the provider to verify, retrying because its resolvers can lag behind.
  6. Inbound. If you asked for it, registers the inbound webhook once the domain is verified.

Options

OptionType
domainstringThe domain to authenticate. Required
subdomainstringReturn-path or MAIL FROM label. SendGrid picks one by default, Postmark uses pm-bounces, SES uses mail. Not used by Mailgun
inbound{ hostname, url, spamCheck?, sendRaw? }Receive email at hostname and POST it to url. Not supported for Amazon SES
overwritebooleanReplace CNAME and MX records that point elsewhere instead of stopping with a conflict
dryRunbooleanPlan only and write nothing to DNS. The provider may still create its domain entry, since that's the only way to get the records
confirm(changes) => Promise<boolean>Called before writing DNS when there are changes. Return false to stop with status aborted
propagation{ timeoutMs?, intervalMs?, lookup? }How long to wait for DNS. Defaults: 300000 ms, polling every 5000 ms
validation{ attempts?, intervalMs? }Verification retries. Defaults: 6 attempts, 10000 ms apart
onEvent(event) => voidProgress, for logs or a live UI. See Events
signalAbortSignalStops waiting and rejects with an AbortError

Result

const result = await setupDomain(email, dns, { domain: 'example.com' });
result.status;      // 'complete' | 'dns-pending' | 'validation-failed' | 'aborted' | 'dry-run'
result.domain;      // { id, domain, valid, records, created }
result.changes;     // the DNS plan: [{ record, action: 'create' | 'update' | 'noop' | 'conflict' }]
result.pending;     // dns-pending: records that weren't visible before the timeout
result.validation;  // the provider's last answer: { valid, results: [{ record, valid, reason? }] }
result.inbound;     // 'created' | 'updated' | 'unchanged', when inbound was set up
StatusMeaning
completeVerified, and inbound set up if requested
dns-pendingRecords were written but aren't visible yet. Run again later
validation-failedRecords are visible but the provider hasn't verified them yet. Run again later
abortedconfirm returned false. Nothing was written
dry-rundryRun was set. changes shows what would be written

Events

onEvent is called as each step happens:

typeFields
domainauth: the provider's domain and the records it needs
planchanges: what will be created, updated, left alone, or conflicts
appliedchanges: what was written
propagationpending, total, elapsedMs, after each DNS check
validationattempt, result, after each verification attempt
inboundhostname, url, action

For example, to stream progress to your own UI per customer:

await setupDomain(email, dns, {
  domain: tenant.domain,
  inbound: { hostname: `parse.${tenant.domain}`, url: `https://app.example.com/inbound/${tenant.id}` },
  onEvent: (e) => {
    if (e.type === 'propagation') progress(tenant.id, `${e.total - e.pending.length} of ${e.total} records live`);
    if (e.type === 'validation' && e.result.valid) progress(tenant.id, 'Verified');
  },
});

checkStatus(email, domain, subdomain?)

A read-only check that changes nothing. It finds the domain at the provider and asks it to re-validate:

import { checkStatus } from 'dkimkit';

const { domain, validation } = await checkStatus(email, 'example.com');
if (!domain) console.log('Not set up with this provider yet');
else if (validation?.valid) console.log('Verified');
else console.log('Not verified yet:', validation?.results.filter((r) => !r.valid));

Email providers

Create one with createEmailProvider(name, credentials, options?):

ProviderNameCredentialsRecords
SendGridsendgridapiKey, with Sender Authentication and Inbound Parse access3 CNAMEs
PostmarkpostmarkaccountToken (an account token, not a server token), and inboundServerId to receive emailDKIM TXT and Return-Path CNAME
MailgunmailgunapiKey, and region: us (default) or euSPF and DKIM TXT, tracking CNAME
Amazon SESsesaccessKeyId, secretAccessKey, optional sessionToken, and region, e.g. us-east-13 DKIM CNAMEs, MAIL FROM MX and SPF
const sendgrid = createEmailProvider('sendgrid', { apiKey }, { subuser: tenant.sendgridSubuser });
const mailgun = createEmailProvider('mailgun', { apiKey, region: 'eu' });
const ses = createEmailProvider('ses', { accessKeyId, secretAccessKey, region: 'us-east-1' });

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.

Each provider also has its own class (SendGridProvider, PostmarkProvider, MailgunProvider, SesProvider) if you'd rather construct it directly.

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.

DNS providers

Create one with createDnsProvider(name, config?):

DNS hostNameConfig
CloudflarecloudflarecloudflareApiToken (or CLOUDFLARE_API_TOKEN), with Zone:Read and DNS:Edit on the zone
Amazon Route 53route53aws: { accessKeyId, secretAccessKey, sessionToken? }, or awsProfile, or the usual AWS environment variables. Optional route53HostedZoneId
Any other hostmanualonManualRecords(records): show the records to whoever adds them

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.

Records added by hand

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:

import { createDnsProvider, describeRecord, detectDnsHost } from 'dkimkit';

const host = await detectDnsHost('example.com');   // { name: 'GoDaddy', provider: null, nameservers: [...] }
const dns = createDnsProvider('manual', {
  onManualRecords: async (records) => {
    console.log(`Add these records at ${host.name ?? 'your DNS host'}:`);
    for (const r of records) console.log(' ', describeRecord(r));
  },
});
const result = await setupDomain(email, dns, { domain: 'example.com', propagation: { timeoutMs: 30 * 60_000 } });

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.

Errors

ErrorWhenUseful fields
ConfigErrorMissing or invalid credentials or optionsmessage
DnsConflictErrorA record the provider needs already points somewhere else. Nothing was writtenconflicts: the conflicting planned changes
ApiErrorAn email or DNS provider rejected a requestprovider, status, body
import { ApiError, DnsConflictError, apiErrorHint } from 'dkimkit';

try {
  await setupDomain(email, dns, { domain: 'example.com' });
} catch (err) {
  if (err instanceof DnsConflictError) console.log(err.message);   // lists each conflict; pass overwrite: true to replace them
  else if (err instanceof ApiError) console.log(err.message, apiErrorHint(err) ?? '');
  else throw err;
}

apiErrorHint(err) returns what to check when a provider rejects the credentials (401 or 403), such as the permissions a key needs.

Verifying webhooks

If you also use the hosted API, the package verifies its webhook signatures:

import { verifyWebhookSignature } from 'dkimkit';

const ok = verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, rawBody, req.headers['x-dkimkit-signature']);

Pass the raw request body, before any JSON parsing. Requests older than 5 minutes are rejected as replays.

CLI

The same flow from a terminal, with no code:

export SENDGRID_API_KEY=SG.xxxx
export CLOUDFLARE_API_TOKEN=xxxx

npx dkimkit --domain example.com --inbound-url https://api.example.com/webhooks/inbound-email

It prints the DNS plan, asks before writing, then shows each step until the domain is verified.

Command
dkimkit --domain example.comSet up a domain
dkimkit status --domain example.comShow the provider's verification status without changing anything
dkimkit uiOpen a local web interface for the same flow
Option
-d, --domainDomain to authenticate
--providersendgrid (default), postmark, mailgun or ses
--regionMailgun us or eu, or the Amazon SES region
--subdomainReturn-path or MAIL FROM label
--dnscloudflare, route53 or manual. Defaults to cloudflare when CLOUDFLARE_API_TOKEN is set
--inbound-url, --inbound-hostReceive email and POST it to this URL. The host defaults to parse.<domain> (Mailgun: the domain itself)
--spam-check, --send-rawInbound options
--overwriteReplace CNAME and MX records that point elsewhere
--dry-runShow the DNS plan without writing anything
--waitSeconds to wait for DNS to propagate (default 300)
--on-behalf-ofRun as a SendGrid subuser
--aws-profile, --zone-idRoute 53 profile and hosted zone
-y, --yesWrite DNS without asking
--jsonPrint the result as JSON, for scripts

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.

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.