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.
@@ -31,9 +31,15 @@
31
31
  header { background: var(--surface); border-bottom: 1px solid var(--border); }
32
32
  .bar { max-width: 960px; margin: 0 auto; padding: 12px 16px; display: flex; align-items: center; gap: 10px; }
33
33
  .mark { width: 26px; height: 26px; border-radius: 7px; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; flex: none; }
34
+ .hist { display: flex; gap: 4px; margin-right: 4px; }
35
+ .hist button { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); cursor: pointer; }
36
+ .hist button:hover:not(:disabled) { background: var(--surface-2); }
37
+ .hist button:disabled { opacity: .4; cursor: default; }
34
38
  .bar h1 { font-size: 15px; margin: 0; font-weight: 600; }
35
39
  .bar .who { margin-left: auto; display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--muted); min-width: 0; }
36
40
  .bar .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
41
+ .bar .who a { color: var(--muted); text-decoration: none; }
42
+ .bar .who a:hover { color: var(--text); }
37
43
  main { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; display: grid; gap: 16px; }
38
44
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
39
45
  .card > h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 14px; font-weight: 600; }
@@ -78,6 +84,26 @@
78
84
  pre { margin: 0; font-family: var(--mono); font-size: 12.5px; background: var(--surface-2); border-radius: 8px; padding: 12px; overflow-x: auto; }
79
85
  .secret { display: flex; gap: 8px; align-items: center; background: var(--surface-2); border-radius: 8px; padding: 8px 10px; }
80
86
  .secret code { flex: 1; overflow-wrap: anywhere; }
87
+ .badge.ok { background: var(--ok-soft); color: var(--ok); }
88
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
89
+ .check { display: flex; gap: 8px; align-items: center; font-size: 14px; font-weight: 500; }
90
+ .check input { width: auto; }
91
+ details.opt > summary { cursor: pointer; font-size: 14px; font-weight: 500; }
92
+ details.opt[open] > summary { margin-bottom: 12px; }
93
+ .group { display: grid; gap: 8px; }
94
+ .group h3 { margin: 6px 0 0; font-size: 14px; font-weight: 600; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
95
+ .dom { border: 1px solid var(--border); border-radius: 8px; }
96
+ .dom > summary { list-style: none; cursor: pointer; padding: 10px 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
97
+ .dom > summary::-webkit-details-marker { display: none; }
98
+ .dom > summary b { font-weight: 600; overflow-wrap: anywhere; }
99
+ .dom > summary .muted { margin-left: auto; font-size: 13px; }
100
+ .dom .body { border-top: 1px solid var(--border); padding: 12px; display: grid; gap: 12px; }
101
+ .records { width: 100%; border-collapse: collapse; font-size: 13px; }
102
+ .records th { text-align: left; font-weight: 500; color: var(--muted); padding: 4px 8px 6px 0; }
103
+ .records td { padding: 6px 8px 6px 0; border-top: 1px solid var(--border); vertical-align: top; }
104
+ .records td.v { font-family: var(--mono); overflow-wrap: anywhere; }
105
+ .records button.copy { font: inherit; font-size: 12px; padding: 1px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; margin-left: 6px; }
106
+ @media (max-width: 720px) { .records .opt-col { display: none; } }
81
107
  .signin { max-width: 420px; margin: 8vh auto 0; }
82
108
  .signin h2 { font-size: 24px; margin: 0 0 4px; text-transform: none; letter-spacing: 0; color: var(--text); }
83
109
  [hidden] { display: none !important; }
@@ -86,12 +112,16 @@
86
112
  <body>
87
113
  <header>
88
114
  <div class="bar">
115
+ <div class="hist" id="hist" hidden>
116
+ <button type="button" id="histBack" aria-label="Back" title="Back"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>
117
+ <button type="button" id="histForward" aria-label="Forward" title="Forward"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg></button>
118
+ </div>
89
119
  <div class="mark" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></div>
90
120
  <a href="/" style="display:flex;align-items:center;gap:10px;color:inherit;text-decoration:none"><h1>__APP_NAME__</h1></a>
91
- <div class="who" id="who" hidden><span id="whoEmail"></span><button class="link" id="signOut" type="button">Sign out</button></div>
121
+ <div class="who" id="who" hidden><span id="whoEmail"></span><a href="/feedback" id="feedback" hidden>Feedback</a><button class="link" id="signOut" type="button">Sign out</button></div>
92
122
  </div>
93
123
  </header>
94
- <main id="app" aria-live="polite"></main>
124
+ <main id="app" aria-live="polite" data-free-domains="__FREE_DOMAINS__"></main>
95
125
 
96
126
  <script nonce="__NONCE__">
97
127
  (() => {
@@ -99,6 +129,24 @@
99
129
  const params = new URLSearchParams(location.search);
100
130
  let me = null;
101
131
 
132
+ // Back and Forward, disabled where the browser says there's nowhere to go.
133
+ {
134
+ const back = document.getElementById('histBack');
135
+ const forward = document.getElementById('histForward');
136
+ back.addEventListener('click', () => history.back());
137
+ forward.addEventListener('click', () => history.forward());
138
+ const nav = window.navigation;
139
+ const update = () => {
140
+ if (!nav || !('canGoBack' in nav)) return;
141
+ back.disabled = !nav.canGoBack;
142
+ forward.disabled = !nav.canGoForward;
143
+ };
144
+ update();
145
+ if (nav) nav.addEventListener('currententrychange', update);
146
+ addEventListener('pageshow', update);
147
+ document.getElementById('hist').hidden = false;
148
+ }
149
+
102
150
  function h(tag, attrs, ...children) {
103
151
  const el = document.createElement(tag);
104
152
  for (const [k, v] of Object.entries(attrs || {})) {
@@ -147,8 +195,8 @@
147
195
  // ---- signed out ----
148
196
  function renderSignIn(sent) {
149
197
  document.getElementById('who').hidden = true;
150
- const email = h('input', { type: 'email', id: 'email', autocomplete: 'email', required: true, placeholder: 'you@company.com' });
151
- const name = h('input', { type: 'text', id: 'name', autocomplete: 'organization', placeholder: 'Acme Inc. (optional)' });
198
+ const email = h('input', { type: 'email', id: 'email', autocomplete: 'email', required: true, placeholder: 'you@example.com' });
199
+ const name = h('input', { type: 'text', id: 'name', autocomplete: 'organization', placeholder: 'Acme Inc. or your name (optional)' });
152
200
  const error = h('div', { class: 'banner bad', role: 'alert', hidden: true });
153
201
  const submit = h('button', { class: 'btn primary', type: 'submit' }, 'Continue with email');
154
202
  const form = h('form', { class: 'grid', onsubmit: async (e) => {
@@ -164,11 +212,16 @@
164
212
  }
165
213
  });
166
214
  } },
167
- h('label', { class: 'field' }, 'Work email', email),
168
- h('label', { class: 'field' }, h('span', {}, 'Company or project name ', h('span', { class: 'hint' }, '(for new accounts)')), name),
215
+ h('label', { class: 'field' }, 'Email', email),
216
+ h('label', { class: 'field' }, h('span', {}, 'Your name, company or project ', h('span', { class: 'hint' }, '(for new accounts)')), name),
169
217
  error, submit,
170
218
  h('p', { class: 'small muted' }, 'No password needed. We email you a sign-in link. New here? The same link creates your account.'));
171
219
 
220
+ // Set by the server from the plan; empty when there's no free-plan limit.
221
+ const free = Number(app.dataset.freeDomains) || 0;
222
+ const intro = free
223
+ ? `Authenticate up to ${free} sending domain${free === 1 ? '' : 's'} for free. No credit card required.`
224
+ : 'Authenticate your sending domains. No credit card required.';
172
225
  app.replaceChildren(h('div', { class: 'signin card grid' },
173
226
  params.get('link') === 'expired' ? h('div', { class: 'banner warn' }, 'That sign-in link expired or was already used. Request a new one below.') : null,
174
227
  sent
@@ -177,7 +230,7 @@
177
230
  h('p', { class: 'muted' }, `We sent a sign-in link to ${sent}. It works once and expires in 15 minutes.`),
178
231
  h('button', { class: 'link', type: 'button', onclick: () => renderSignIn() }, 'Use a different email'))
179
232
  : h('div', { class: 'grid' },
180
- h('div', {}, h('h2', {}, 'Start free'), h('p', { class: 'muted', style: 'margin:0' }, 'Authenticate up to 3 sending domains for free. No credit card required.')),
233
+ h('div', {}, h('h2', {}, 'Start free'), h('p', { class: 'muted', style: 'margin:0' }, intro)),
181
234
  form)));
182
235
  if (!sent) email.focus();
183
236
  }
@@ -185,6 +238,18 @@
185
238
  // ---- signed in ----
186
239
  function planCard() {
187
240
  const b = me.billing;
241
+ if (!me.billing_enabled && me.free_plan) {
242
+ const limit = me.free_plan.free_domains;
243
+ const used = me.usage.current_domains;
244
+ const pct = Math.min(100, Math.round((used / limit) * 100));
245
+ return h('section', { class: 'card grid' },
246
+ h('div', { class: 'row', style: 'justify-content:space-between' }, h('h2', { style: 'margin:0' }, 'Plan'), h('span', { class: 'badge' }, 'Free')),
247
+ h('div', { class: 'row', style: 'justify-content:space-between' },
248
+ h('b', {}, `${used} of ${limit} free domains used`),
249
+ used >= limit ? h('span', { class: 'small', style: 'color:var(--warn)' }, 'Limit reached') : null),
250
+ h('div', { class: used >= limit ? 'meter full' : 'meter', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': limit, 'aria-valuenow': used }, h('div', { style: `width:${pct}%` })),
251
+ h('p', { class: 'small muted', style: 'margin:0' }, 'Paid plans with unlimited domains are coming soon. ', h('a', { href: '/pricing' }, 'See pricing')));
252
+ }
188
253
  if (!me.billing_enabled) {
189
254
  return h('section', { class: 'card grid' }, h('h2', {}, 'Plan'), h('p', { class: 'muted' }, 'Billing is not enabled on this server.'));
190
255
  }
@@ -316,6 +381,215 @@
316
381
  }
317
382
 
318
383
 
384
+ // ---- domains ----
385
+ const STATUS_LABELS = {
386
+ pending: ['Setting up', 'badge'], awaiting_dns: ['Waiting for DNS', 'badge warn'], verifying: ['Verifying', 'badge paid'],
387
+ verified: ['Verified', 'badge ok'], conflict: ['Conflict', 'badge bad'], failed: ['Failed', 'badge bad'],
388
+ };
389
+ const domainsBox = h('div', { class: 'grid' });
390
+ const openDomains = new Set();
391
+ let domains = null;
392
+ let refreshTimer = null;
393
+
394
+ /** The record name as most DNS hosts want it: relative to the domain, "@" for the domain itself. */
395
+ const hostPart = (name, domain) => (name === domain ? '@' : name.endsWith('.' + domain) ? name.slice(0, -domain.length - 1) : name);
396
+
397
+ function download(filename, text, type) {
398
+ const a = h('a', { href: URL.createObjectURL(new Blob([text], { type })), download: filename });
399
+ document.body.append(a);
400
+ a.click();
401
+ a.remove();
402
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
403
+ }
404
+
405
+ /** BIND zone file lines with absolute names, which Cloudflare, GoDaddy and most DNS hosts can import. */
406
+ function zoneFile(list) {
407
+ const fqdn = (n) => (n.endsWith('.') ? n : n + '.');
408
+ const txt = (v) => (v.match(/.{1,255}/g) || ['']).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' ');
409
+ const lines = [];
410
+ for (const d of list) {
411
+ lines.push(`; ${d.domain}`);
412
+ for (const r of d.records) {
413
+ const value = r.type === 'TXT' ? txt(r.value) : r.type === 'MX' ? `${r.priority ?? 10} ${fqdn(r.value)}` : fqdn(r.value);
414
+ lines.push(`${fqdn(r.name)}\t3600\tIN\t${r.type}\t${value}`);
415
+ }
416
+ lines.push('');
417
+ }
418
+ return lines.join('\n');
419
+ }
420
+
421
+ function csv(list) {
422
+ const cell = (v) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
423
+ const rows = [['domain', 'dns_host', 'type', 'host', 'name', 'value', 'priority', 'status']];
424
+ for (const d of list) {
425
+ for (const r of d.records) rows.push([d.domain, d.dns_host?.name || '', r.type, hostPart(r.name, d.domain), r.name, r.value, r.priority ?? '', r.status]);
426
+ }
427
+ return rows.map((r) => r.map(cell).join(',')).join('\n') + '\n';
428
+ }
429
+
430
+ const needsRecords = (d) => d.dns.provider === 'manual' && ['pending', 'awaiting_dns', 'conflict'].includes(d.status);
431
+
432
+ function domainRow(d) {
433
+ const [label, cls] = STATUS_LABELS[d.status] || [d.status, 'badge'];
434
+ const copy = (text) => h('button', { class: 'copy', type: 'button', onclick: async (e) => {
435
+ try { await navigator.clipboard.writeText(text); e.target.textContent = 'Copied'; } catch { e.target.textContent = 'Failed'; }
436
+ setTimeout(() => (e.target.textContent = 'Copy'), 1200);
437
+ } }, 'Copy');
438
+ const records = h('table', { class: 'records' },
439
+ h('thead', {}, h('tr', {}, h('th', {}, 'Type'), h('th', {}, 'Host'), h('th', {}, 'Value'), h('th', { class: 'opt-col' }, 'Priority'), h('th', { class: 'opt-col' }, 'Status'))),
440
+ h('tbody', {}, d.records.map((r) => h('tr', {},
441
+ h('td', {}, r.type),
442
+ h('td', { class: 'v', title: r.name }, hostPart(r.name, d.domain), copy(hostPart(r.name, d.domain))),
443
+ h('td', { class: 'v' }, r.value, copy(r.value)),
444
+ h('td', { class: 'opt-col' }, r.priority ?? ''),
445
+ h('td', { class: 'opt-col' }, r.status)))));
446
+
447
+ const check = h('button', { class: 'btn', type: 'button', onclick: () => busy(check, 'Checking…', async () => {
448
+ try { await api('POST', `/dashboard/api/domains/${d.id}/verify`); setTimeout(loadDomains, 4000); } catch (err) { alert(err.message); }
449
+ }) }, 'Check now');
450
+ const remove = h('button', { class: 'link', type: 'button', onclick: async () => {
451
+ if (!confirm(`Remove ${d.domain}? This also removes it${d.inbound ? ' and its inbound parse' : ''} from your email provider. Your DNS records are left as they are.`)) return;
452
+ try { await api('DELETE', `/dashboard/api/domains/${d.id}`); openDomains.delete(d.id); await loadDomains(); } catch (err) { alert(err.message); }
453
+ } }, 'Remove');
454
+
455
+ const notes = [];
456
+ if (d.status === 'verified') notes.push(h('div', { class: 'banner ok' }, `Sender authentication is verified${d.inbound ? ` and email to ${d.inbound.hostname} is forwarded to ${d.inbound.url}` : ''}.`));
457
+ else if (d.dns.provider !== 'manual') notes.push(h('p', { class: 'small muted', style: 'margin:0' }, `Records are written to ${d.dns_host?.name || d.dns.provider} for you. Nothing to do.`));
458
+ else if (needsRecords(d)) notes.push(h('p', { class: 'small muted', style: 'margin:0' }, `Add these records at ${d.dns_host?.name || 'your DNS host'}. We check automatically and verify the domain as soon as they're live.`));
459
+ if (d.inbound && d.status !== 'verified') notes.push(h('p', { class: 'small muted', style: 'margin:0' }, `Inbound parse for ${d.inbound.hostname} is set up once the domain is verified.`));
460
+ if (d.last_error) notes.push(h('div', { class: d.status === 'failed' || d.status === 'conflict' ? 'banner bad' : 'banner warn' }, d.last_error));
461
+ if (d.conflicts?.length) notes.push(h('div', { class: 'banner bad' }, d.conflicts.map((c) => h('div', {}, `${c.type} ${c.name} already exists with another value${c.reason ? `: ${c.reason}` : ''}. Replace it with the value above.`))));
462
+
463
+ const el = h('details', { class: 'dom', open: openDomains.has(d.id), ontoggle: () => (el.open ? openDomains.add(d.id) : openDomains.delete(d.id)) },
464
+ h('summary', {}, h('b', {}, d.domain), h('span', { class: cls }, label), d.inbound ? h('span', { class: 'badge' }, 'Inbound') : null, h('span', { class: 'muted' }, d.dns_host?.name || '')),
465
+ h('div', { class: 'body' }, ...notes, records,
466
+ h('div', { class: 'row' }, d.status === 'verified' ? null : check,
467
+ h('button', { class: 'btn', type: 'button', onclick: () => download(`${d.domain}.zone.txt`, zoneFile([d]), 'text/plain') }, 'Zone file'),
468
+ h('span', { style: 'margin-left:auto' }, remove))));
469
+ return el;
470
+ }
471
+
472
+ function addForm() {
473
+ const ready = me.account.email_credentials_set;
474
+ const text = h('textarea', { rows: 6, placeholder: 'example.com\nexample.net\nexample.org', spellcheck: 'false', 'aria-label': 'Domains, one per line' });
475
+ const count = h('span', { class: 'small muted' });
476
+ const countDomains = () => text.value.split(/[\s,;]+/).filter(Boolean).length;
477
+ const submit = h('button', { class: 'btn primary', type: 'submit', disabled: !ready }, 'Add domains');
478
+ text.addEventListener('input', () => {
479
+ const n = countDomains();
480
+ count.textContent = n ? `${n} domain${n === 1 ? '' : 's'}` : '';
481
+ submit.textContent = n ? `Add ${n} domain${n === 1 ? '' : 's'}` : 'Add domains';
482
+ });
483
+
484
+ const inboundOn = h('input', { type: 'checkbox' });
485
+ const inboundUrl = h('input', { type: 'url', placeholder: 'https://api.yourapp.com/inbound-email' });
486
+ const prefix = h('input', { type: 'text', placeholder: me.account.email_provider === 'mailgun' ? 'the domain itself' : 'parse', spellcheck: 'false' });
487
+ const inboundFields = h('div', { class: 'grid', hidden: true },
488
+ h('label', { class: 'field' }, h('span', {}, 'Webhook URL ', h('span', { class: 'hint' }, '(received email is posted here)')), inboundUrl),
489
+ h('label', { class: 'field' }, h('span', {}, 'Receiving subdomain ', h('span', { class: 'hint' }, '(gets the MX record, e.g. parse.example.com; leave blank for the default)')), prefix));
490
+ inboundOn.addEventListener('change', () => { inboundFields.hidden = !inboundOn.checked; if (inboundOn.checked) inboundUrl.focus(); });
491
+
492
+ const cf = h('input', { type: 'password', autocomplete: 'off', placeholder: 'Cloudflare API token (Zone → DNS → Edit)' });
493
+ const awsId = h('input', { type: 'text', autocomplete: 'off', spellcheck: 'false', placeholder: 'AWS access key ID' });
494
+ const awsSecret = h('input', { type: 'password', autocomplete: 'off', placeholder: 'AWS secret access key' });
495
+ const result = h('div', { class: 'grid' });
496
+
497
+ const form = h('form', { class: 'grid', onsubmit: async (e) => {
498
+ e.preventDefault();
499
+ result.replaceChildren();
500
+ const body = { domains: text.value };
501
+ if (inboundOn.checked) body.inbound = { url: inboundUrl.value.trim(), ...(prefix.value.trim() ? { hostname_prefix: prefix.value.trim() } : {}) };
502
+ if (cf.value.trim()) body.cloudflare_api_token = cf.value.trim();
503
+ if (awsId.value.trim() || awsSecret.value.trim()) Object.assign(body, { aws_access_key_id: awsId.value.trim(), aws_secret_access_key: awsSecret.value.trim() });
504
+ await busy(submit, `Adding ${countDomains()}…`, async () => {
505
+ try {
506
+ const { data } = await api('POST', '/dashboard/api/domains', body);
507
+ const ok = data.filter((r) => r.ok);
508
+ const failed = data.filter((r) => !r.ok);
509
+ const auto = ok.filter((r) => r.dns !== 'manual').length;
510
+ result.replaceChildren(
511
+ ok.length ? h('div', { class: 'banner ok' }, `Added ${ok.length} domain${ok.length === 1 ? '' : 's'}.`,
512
+ auto ? ` ${auto} will be set up automatically.` : '',
513
+ ok.length - auto ? ` ${ok.length - auto} need${ok.length - auto === 1 ? 's' : ''} DNS records added. They're listed below.` : '') : null,
514
+ failed.length ? h('div', { class: 'banner bad' }, h('b', {}, `${failed.length} couldn't be added:`),
515
+ h('ul', { style: 'margin:6px 0 0;padding-left:18px' }, failed.map((r) => h('li', {}, h('code', {}, r.domain), ` ${r.error.message}`)))) : null);
516
+ if (ok.length) {
517
+ text.value = failed.filter((r) => r.error.type !== 'conflict').map((r) => r.domain).join('\n');
518
+ text.dispatchEvent(new Event('input'));
519
+ ok.forEach((r) => r.dns === 'manual' && openDomains.add(r.id));
520
+ }
521
+ await loadDomains();
522
+ } catch (err) { result.replaceChildren(h('div', { class: 'banner bad' }, err.message)); }
523
+ });
524
+ } },
525
+ !ready ? h('div', { class: 'banner warn' }, 'Connect your email provider in Settings below before adding domains.') : null,
526
+ h('label', { class: 'field' }, h('span', {}, 'Domains ', h('span', { class: 'hint' }, '(one per line or comma separated, up to 100, from any registrar)')), text),
527
+ h('label', { class: 'check' }, inboundOn, 'Also receive email (inbound parse)'),
528
+ inboundFields,
529
+ h('details', { class: 'opt' }, h('summary', {}, 'Write DNS records automatically (optional)'),
530
+ h('div', { class: 'grid' },
531
+ h('p', { class: 'small muted', style: 'margin:0' }, 'For domains whose DNS is on Cloudflare or Amazon Route 53. They\'re stored encrypted, and used only to write these records. Domains on any other DNS host (GoDaddy, Namecheap, Hostinger…) get records to copy, or a zone file to import.'),
532
+ cf, h('div', { class: 'cols' }, awsId, awsSecret))),
533
+ h('div', { class: 'row' }, submit, count),
534
+ h('p', { class: 'small muted', style: 'margin:0' }, h('span', { class: 'badge' }, 'Coming soon'), ' Buy domains in bulk: register new sending domains here and have them authenticated automatically.'),
535
+ result);
536
+ return form;
537
+ }
538
+
539
+ function renderDomains() {
540
+ if (domains === null) return domainsBox.replaceChildren(h('p', { class: 'muted small', style: 'margin:0' }, 'Loading domains…'));
541
+ if (domains.length === 0) return domainsBox.replaceChildren(h('p', { class: 'muted small', style: 'margin:0' }, 'No domains yet. Paste your domains above to set up sender authentication and inbound email.'));
542
+
543
+ const parts = [];
544
+ const manual = domains.filter(needsRecords);
545
+ if (manual.length) {
546
+ const byHost = new Map();
547
+ for (const d of manual) {
548
+ const key = d.dns_host?.name || 'Unknown DNS host';
549
+ byHost.set(key, [...(byHost.get(key) || []), d]);
550
+ }
551
+ parts.push(h('div', { class: 'row', style: 'justify-content:space-between' },
552
+ h('b', {}, `${manual.length} domain${manual.length === 1 ? '' : 's'} need DNS records`),
553
+ h('div', { class: 'row' },
554
+ h('button', { class: 'btn', type: 'button', onclick: () => download('dns-records.csv', csv(manual), 'text/csv') }, 'CSV'),
555
+ h('button', { class: 'btn', type: 'button', onclick: () => download('dns-records.zone.txt', zoneFile(manual), 'text/plain') }, 'Zone file'))));
556
+ for (const [host, list] of byHost) {
557
+ parts.push(h('div', { class: 'group' },
558
+ h('h3', {}, host, h('span', { class: 'small muted', style: 'font-weight:400' }, `${list.length} domain${list.length === 1 ? '' : 's'}`),
559
+ h('button', { class: 'link', type: 'button', onclick: () => download(`${host.toLowerCase().replace(/\W+/g, '-')}-records.csv`, csv(list), 'text/csv') }, 'Download CSV')),
560
+ list.map(domainRow)));
561
+ }
562
+ }
563
+ const rest = domains.filter((d) => !needsRecords(d));
564
+ if (rest.length) {
565
+ const order = ['conflict', 'failed', 'pending', 'awaiting_dns', 'verifying', 'verified'];
566
+ rest.sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status) || a.domain.localeCompare(b.domain));
567
+ const verified = rest.filter((d) => d.status === 'verified').length;
568
+ parts.push(h('div', { class: 'group' },
569
+ h('h3', {}, manual.length ? 'Other domains' : 'All domains', h('span', { class: 'small muted', style: 'font-weight:400' }, `${verified} of ${rest.length} verified`)),
570
+ rest.map(domainRow)));
571
+ }
572
+ domainsBox.replaceChildren(...parts);
573
+ }
574
+
575
+ async function loadDomains() {
576
+ clearTimeout(refreshTimer);
577
+ try {
578
+ domains = (await api('GET', '/dashboard/api/domains')).data;
579
+ renderDomains();
580
+ } catch (err) {
581
+ if (domains === null) domainsBox.replaceChildren(h('div', { class: 'banner bad' }, err.message));
582
+ }
583
+ // Keep statuses fresh while anything is still in progress.
584
+ if (domains?.some((d) => !['verified', 'failed'].includes(d.status))) {
585
+ refreshTimer = setTimeout(() => (document.hidden ? (refreshTimer = setTimeout(loadDomains, 5000)) : loadDomains()), 20000);
586
+ }
587
+ }
588
+
589
+ function domainsCard() {
590
+ return h('section', { class: 'card grid' }, h('h2', {}, 'Domains'), addForm(), domainsBox);
591
+ }
592
+
319
593
  function usageCard() {
320
594
  const u = me.usage;
321
595
  const stat = (n, label) => h('div', { class: 'stat' }, h('b', {}, String(n)), h('span', { class: 'small muted' }, label));
@@ -343,6 +617,7 @@
343
617
  function render(flags = {}) {
344
618
  document.getElementById('who').hidden = false;
345
619
  document.getElementById('whoEmail').textContent = me.email || me.account.name;
620
+ document.getElementById('feedback').hidden = !me.feedback_enabled;
346
621
  const banners = [];
347
622
  if (params.get('welcome')) banners.push(h('div', { class: 'banner ok' }, 'Your account is ready. Connect your email provider in Settings and create an API key to get started.'));
348
623
  if (params.get('billing') === 'return') {
@@ -355,7 +630,9 @@
355
630
  }
356
631
  if (params.get('billing') === 'canceled') banners.push(h('div', { class: 'banner warn' }, 'Checkout canceled. Nothing was charged.'));
357
632
  if (flags.saved) banners.push(h('div', { class: 'banner ok' }, 'Settings saved.'));
358
- app.replaceChildren(...banners, planCard(), ...[pricingCard(), usageCard(), apiKeyCard(), settingsCard(), quickstartCard()].filter(Boolean));
633
+ app.replaceChildren(...banners, domainsCard(), planCard(), ...[pricingCard(), usageCard(), apiKeyCard(), settingsCard(), quickstartCard()].filter(Boolean));
634
+ renderDomains();
635
+ if (domains === null) loadDomains();
359
636
  }
360
637
 
361
638
  async function load() {
@@ -380,6 +657,8 @@
380
657
  document.getElementById('signOut').addEventListener('click', async () => {
381
658
  await api('POST', '/dashboard/api/logout').catch(() => {});
382
659
  me = null;
660
+ domains = null;
661
+ clearTimeout(refreshTimer);
383
662
  history.replaceState(null, '', '/dashboard');
384
663
  renderSignIn();
385
664
  });