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.
- package/README.md +8 -0
- package/dist/src/cli.js +0 -0
- package/dist/src/hosted/api.d.ts +12 -0
- package/dist/src/hosted/api.js +147 -9
- package/dist/src/hosted/api.js.map +1 -1
- package/dist/src/hosted/auth.d.ts +22 -12
- package/dist/src/hosted/auth.js +27 -1
- package/dist/src/hosted/auth.js.map +1 -1
- package/dist/src/hosted/bulk.d.ts +47 -0
- package/dist/src/hosted/bulk.js +66 -0
- package/dist/src/hosted/bulk.js.map +1 -0
- package/dist/src/hosted/cli.js +21 -8
- package/dist/src/hosted/cli.js.map +1 -1
- package/dist/src/hosted/service.d.ts +4 -0
- package/dist/src/hosted/service.js +23 -2
- package/dist/src/hosted/service.js.map +1 -1
- package/dist/src/hosted/site.d.ts +31 -1
- package/dist/src/hosted/site.js +197 -16
- package/dist/src/hosted/site.js.map +1 -1
- package/docs/getting-started.md +9 -0
- package/docs/library.md +253 -0
- package/docs/self-hosting.md +12 -7
- package/package.json +1 -1
- package/web/dashboard.html +287 -8
package/web/dashboard.html
CHANGED
|
@@ -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@
|
|
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' }, '
|
|
168
|
-
h('label', { class: 'field' }, h('span', {}, '
|
|
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' },
|
|
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
|
});
|