dkimkit 0.0.0-stage → 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 +161 -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 +107 -0
- package/dist/src/hosted/api.js +919 -0
- package/dist/src/hosted/api.js.map +1 -0
- package/dist/src/hosted/auth.d.ts +80 -0
- package/dist/src/hosted/auth.js +147 -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/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.d.ts +2 -0
- package/dist/src/hosted/cli.js +318 -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 +170 -0
- package/dist/src/hosted/service.js +603 -0
- package/dist/src/hosted/service.js.map +1 -0
- package/dist/src/hosted/site.d.ts +84 -0
- package/dist/src/hosted/site.js +653 -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 +66 -0
- package/docs/hosted-api.md +192 -0
- package/docs/library.md +253 -0
- package/docs/self-hosting.md +153 -0
- package/docs/widget.md +121 -0
- package/package.json +63 -4
- package/web/dashboard.html +669 -0
- package/web/index.html +697 -0
- package/web/widget-frame.html +480 -0
- package/web/widget.js +91 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>DKIMkit</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--accent: #3b5bdb;
|
|
10
|
+
--on-accent: #ffffff;
|
|
11
|
+
--accent-soft: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
12
|
+
--bg: #ffffff;
|
|
13
|
+
--surface-2: #f3f4f7;
|
|
14
|
+
--border: #e1e4ea;
|
|
15
|
+
--text: #16191f;
|
|
16
|
+
--muted: #5f6775;
|
|
17
|
+
--ok: #1d8348;
|
|
18
|
+
--ok-soft: #e3f4e9;
|
|
19
|
+
--warn: #9a5800;
|
|
20
|
+
--warn-soft: #fcf0dc;
|
|
21
|
+
--bad: #c2302b;
|
|
22
|
+
--bad-soft: #fceaea;
|
|
23
|
+
--radius: 10px;
|
|
24
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
25
|
+
color-scheme: light;
|
|
26
|
+
}
|
|
27
|
+
@media (prefers-color-scheme: dark) {
|
|
28
|
+
:root:not([data-theme="light"]) {
|
|
29
|
+
--bg: #16191f; --surface-2: #1f232b; --border: #2c313b; --text: #e9ebef; --muted: #9ca4b2;
|
|
30
|
+
--ok: #4cc27e; --ok-soft: #12301e; --warn: #f0a640; --warn-soft: #33260f; --bad: #ff6b65; --bad-soft: #3a1717;
|
|
31
|
+
color-scheme: dark;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
:root[data-theme="dark"] {
|
|
35
|
+
--bg: #16191f; --surface-2: #1f232b; --border: #2c313b; --text: #e9ebef; --muted: #9ca4b2;
|
|
36
|
+
--ok: #4cc27e; --ok-soft: #12301e; --warn: #f0a640; --warn-soft: #33260f; --bad: #ff6b65; --bad-soft: #3a1717;
|
|
37
|
+
color-scheme: dark;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
* { box-sizing: border-box; }
|
|
41
|
+
html, body { margin: 0; background: transparent; }
|
|
42
|
+
body { color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
43
|
+
#app { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
|
|
44
|
+
h1 { font-size: 17px; margin: 0 0 4px; font-weight: 600; outline: none; }
|
|
45
|
+
p { margin: 0 0 12px; }
|
|
46
|
+
.muted { color: var(--muted); }
|
|
47
|
+
.small { font-size: 13px; }
|
|
48
|
+
a { color: var(--accent); }
|
|
49
|
+
|
|
50
|
+
.stack { display: grid; gap: 14px; }
|
|
51
|
+
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
52
|
+
label { display: grid; gap: 5px; font-size: 13px; font-weight: 500; }
|
|
53
|
+
input {
|
|
54
|
+
width: 100%; font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--border);
|
|
55
|
+
border-radius: calc(var(--radius) * .7); padding: 9px 11px; outline: none;
|
|
56
|
+
}
|
|
57
|
+
input:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
58
|
+
|
|
59
|
+
button { font: inherit; cursor: pointer; }
|
|
60
|
+
.btn { font-size: 14px; font-weight: 500; border-radius: calc(var(--radius) * .7); padding: 9px 16px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
|
|
61
|
+
.btn:hover { background: var(--surface-2); }
|
|
62
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
63
|
+
.btn.primary:hover { filter: brightness(1.08); }
|
|
64
|
+
.btn:disabled { opacity: .55; cursor: default; }
|
|
65
|
+
.link { background: none; border: 0; padding: 0; color: var(--accent); font-size: 13px; text-decoration: underline; text-underline-offset: 2px; }
|
|
66
|
+
.copy { font-size: 12px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); }
|
|
67
|
+
|
|
68
|
+
.option { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; width: 100%; text-align: left;
|
|
69
|
+
padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); }
|
|
70
|
+
.option:hover { border-color: var(--accent); }
|
|
71
|
+
.option b { display: block; font-weight: 500; }
|
|
72
|
+
.option span.muted { font-size: 13px; }
|
|
73
|
+
.option .chev { color: var(--muted); }
|
|
74
|
+
.hint { border-radius: var(--radius); padding: 12px 14px; background: var(--accent-soft); }
|
|
75
|
+
|
|
76
|
+
.banner { border-radius: calc(var(--radius) * .7); padding: 10px 12px; font-size: 13px; }
|
|
77
|
+
.banner.bad { background: var(--bad-soft); color: var(--bad); }
|
|
78
|
+
.banner.warn { background: var(--warn-soft); color: var(--warn); }
|
|
79
|
+
|
|
80
|
+
.records { display: grid; gap: 8px; }
|
|
81
|
+
.rec { border: 1px solid var(--border); border-radius: calc(var(--radius) * .8); padding: 10px 12px; display: grid; gap: 6px; }
|
|
82
|
+
.rec-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
83
|
+
.type { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
84
|
+
.kv { display: grid; grid-template-columns: 48px 1fr auto; gap: 4px 10px; align-items: center; }
|
|
85
|
+
.kv .k { font-size: 12px; color: var(--muted); }
|
|
86
|
+
.kv code { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
87
|
+
.chip { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 99px; white-space: nowrap; }
|
|
88
|
+
.chip.live { background: var(--ok-soft); color: var(--ok); }
|
|
89
|
+
.chip.pending { background: var(--surface-2); color: var(--muted); }
|
|
90
|
+
.chip.conflict { background: var(--bad-soft); color: var(--bad); }
|
|
91
|
+
|
|
92
|
+
.status { display: flex; gap: 12px; align-items: flex-start; }
|
|
93
|
+
.spinner { width: 20px; height: 20px; flex: none; border-radius: 50%; border: 2.5px solid var(--accent-soft); border-top-color: var(--accent); animation: spin .9s linear infinite; margin-top: 2px; }
|
|
94
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
95
|
+
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
|
96
|
+
.done-icon { width: 40px; height: 40px; border-radius: 50%; background: var(--ok); color: #fff; display: grid; place-items: center; }
|
|
97
|
+
|
|
98
|
+
pre { margin: 0; font-family: var(--mono); font-size: 12px; background: var(--surface-2); border-radius: 8px; padding: 10px; overflow-x: auto; }
|
|
99
|
+
ol.steps { margin: 0; padding-left: 18px; display: grid; gap: 4px; }
|
|
100
|
+
@media (max-width: 420px) { #app { padding: 16px; } .kv { grid-template-columns: 1fr auto; } .kv .k { grid-column: 1 / -1; } }
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<div id="app" aria-live="polite"></div>
|
|
105
|
+
<script nonce="__NONCE__">
|
|
106
|
+
(() => {
|
|
107
|
+
const CONFIG = __CONFIG__;
|
|
108
|
+
const token = new URLSearchParams(location.search).get('session');
|
|
109
|
+
const app = document.getElementById('app');
|
|
110
|
+
const POLL_MS = 4000;
|
|
111
|
+
|
|
112
|
+
// ---- theme ----
|
|
113
|
+
const root = document.documentElement;
|
|
114
|
+
if (CONFIG.theme.mode === 'light' || CONFIG.theme.mode === 'dark') root.dataset.theme = CONFIG.theme.mode;
|
|
115
|
+
if (CONFIG.theme.radius !== null) root.style.setProperty('--radius', CONFIG.theme.radius + 'px');
|
|
116
|
+
if (CONFIG.theme.accent) {
|
|
117
|
+
root.style.setProperty('--accent', CONFIG.theme.accent);
|
|
118
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(CONFIG.theme.accent.slice(i, i + 2), 16) / 255)
|
|
119
|
+
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
120
|
+
root.style.setProperty('--on-accent', 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.4 ? '#111111' : '#ffffff');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- parent messaging ----
|
|
124
|
+
let parentOrigin = null;
|
|
125
|
+
// Loaders from before the rename (bundled, or cached for up to 5 minutes) say
|
|
126
|
+
// 'sds-host' and only listen for 'sds-widget', so answer in the parent's naming.
|
|
127
|
+
const HOST_SOURCES = { 'dkimkit-host': 'dkimkit-widget', 'sds-host': 'sds-widget' };
|
|
128
|
+
let widgetSource = 'dkimkit-widget';
|
|
129
|
+
const outbox = [];
|
|
130
|
+
const embedded = window.parent !== window;
|
|
131
|
+
window.addEventListener('message', (e) => {
|
|
132
|
+
if (e.source !== window.parent || !e.data || !Object.hasOwn(HOST_SOURCES, e.data.source)) return;
|
|
133
|
+
if (!CONFIG.allowedOrigins.includes(e.origin)) return;
|
|
134
|
+
if (e.data.type === 'hello' && !parentOrigin) {
|
|
135
|
+
parentOrigin = e.origin;
|
|
136
|
+
widgetSource = HOST_SOURCES[e.data.source];
|
|
137
|
+
outbox.splice(0).forEach((msg) => send({ ...msg, source: widgetSource }));
|
|
138
|
+
reportSize(true);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const send = (msg) => window.parent.postMessage(msg, parentOrigin);
|
|
142
|
+
function post(msg) {
|
|
143
|
+
if (!embedded) return;
|
|
144
|
+
if (parentOrigin) send({ ...msg, source: widgetSource });
|
|
145
|
+
else outbox.push(msg);
|
|
146
|
+
}
|
|
147
|
+
let lastHeight = 0;
|
|
148
|
+
function reportSize(force) {
|
|
149
|
+
const height = Math.ceil(app.getBoundingClientRect().height);
|
|
150
|
+
if (force || height !== lastHeight) {
|
|
151
|
+
lastHeight = height;
|
|
152
|
+
post({ type: 'resize', height });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
new ResizeObserver(() => reportSize(false)).observe(app);
|
|
156
|
+
|
|
157
|
+
// ---- state ----
|
|
158
|
+
// focusNext: move focus into the new view only after the user navigated (never on load,
|
|
159
|
+
// which would pull focus away from the host page).
|
|
160
|
+
const state = { view: 'loading', session: null, busy: false, error: null, fatal: null, focusNext: false };
|
|
161
|
+
let pollTimer = null;
|
|
162
|
+
const domain = () => state.session?.domain;
|
|
163
|
+
|
|
164
|
+
async function api(method, path, body) {
|
|
165
|
+
const res = await fetch(path, {
|
|
166
|
+
method,
|
|
167
|
+
headers: { authorization: 'Bearer ' + token, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
168
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
169
|
+
});
|
|
170
|
+
const data = await res.json().catch(() => ({}));
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const err = new Error(data.error?.message || 'Something went wrong. Please try again.');
|
|
173
|
+
err.type = data.error?.type;
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
return data;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function nextView(session) {
|
|
180
|
+
const d = session.domain;
|
|
181
|
+
if (session.needs_domain) return 'domain';
|
|
182
|
+
if (d.status === 'verified') return 'done';
|
|
183
|
+
if (d.dns_provider !== 'manual') return 'progress';
|
|
184
|
+
if (['cloudflare', 'route53', 'records', 'progress'].includes(state.view)) return state.view;
|
|
185
|
+
return 'choose';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setSession(session, view) {
|
|
189
|
+
const before = state.session?.domain?.status;
|
|
190
|
+
state.session = session;
|
|
191
|
+
state.view = view ?? nextView(session);
|
|
192
|
+
if (session.domain && session.domain.status !== before) post({ type: 'status', domain: session.domain });
|
|
193
|
+
render();
|
|
194
|
+
schedulePoll();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function schedulePoll() {
|
|
198
|
+
clearTimeout(pollTimer);
|
|
199
|
+
const d = domain();
|
|
200
|
+
const watching = ['progress', 'records'].includes(state.view) && d && !['verified', 'failed', 'conflict'].includes(d.status);
|
|
201
|
+
if (watching) pollTimer = setTimeout(refresh, POLL_MS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function refresh() {
|
|
205
|
+
if (document.hidden) return schedulePoll();
|
|
206
|
+
try {
|
|
207
|
+
setSession(await api('GET', '/v1/widget/session'));
|
|
208
|
+
} catch (err) {
|
|
209
|
+
handleError(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function handleError(err) {
|
|
214
|
+
if (err.type === 'session_expired') {
|
|
215
|
+
state.fatal = 'This setup link has expired. Close it and open the setup again to continue.';
|
|
216
|
+
post({ type: 'error', error: { type: 'session_expired', message: err.message } });
|
|
217
|
+
} else {
|
|
218
|
+
state.error = err.message;
|
|
219
|
+
}
|
|
220
|
+
render();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function act(fn) {
|
|
224
|
+
if (state.busy) return;
|
|
225
|
+
state.busy = true;
|
|
226
|
+
state.error = null;
|
|
227
|
+
render();
|
|
228
|
+
try {
|
|
229
|
+
await fn();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
handleError(err);
|
|
232
|
+
} finally {
|
|
233
|
+
state.busy = false;
|
|
234
|
+
state.focusNext = true;
|
|
235
|
+
render();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- rendering helpers ----
|
|
240
|
+
function h(tag, attrs, ...children) {
|
|
241
|
+
const el = document.createElement(tag);
|
|
242
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
243
|
+
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
|
244
|
+
else if (k === 'class') el.className = v;
|
|
245
|
+
else if (v === true) el.setAttribute(k, '');
|
|
246
|
+
else if (v !== false && v != null) el.setAttribute(k, v);
|
|
247
|
+
}
|
|
248
|
+
for (const c of children.flat()) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
|
249
|
+
return el;
|
|
250
|
+
}
|
|
251
|
+
const button = (label, onclick, primary, extra = {}) =>
|
|
252
|
+
h('button', { type: 'button', class: primary ? 'btn primary' : 'btn', onclick, disabled: state.busy, ...extra }, label);
|
|
253
|
+
const errorBanner = () => (state.error ? h('div', { class: 'banner bad', role: 'alert' }, state.error) : null);
|
|
254
|
+
const go = (view) => () => { state.view = view; state.error = null; state.focusNext = true; render(); schedulePoll(); };
|
|
255
|
+
|
|
256
|
+
function copyButton(text) {
|
|
257
|
+
return h('button', {
|
|
258
|
+
type: 'button', class: 'copy',
|
|
259
|
+
onclick: async (e) => {
|
|
260
|
+
try {
|
|
261
|
+
await navigator.clipboard.writeText(text);
|
|
262
|
+
e.target.textContent = 'Copied';
|
|
263
|
+
} catch {
|
|
264
|
+
e.target.textContent = 'Select & copy';
|
|
265
|
+
}
|
|
266
|
+
setTimeout(() => (e.target.textContent = 'Copy'), 1400);
|
|
267
|
+
},
|
|
268
|
+
}, 'Copy');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Most registrars want the host relative to the domain ("em123", or "@" for the apex). */
|
|
272
|
+
function hostLabel(name, zone) {
|
|
273
|
+
if (name === zone) return '@';
|
|
274
|
+
return name.endsWith('.' + zone) ? name.slice(0, -(zone.length + 1)) : name;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const CHIP = { live: 'Found', pending: 'Waiting', conflict: 'Conflict' };
|
|
278
|
+
|
|
279
|
+
function recordList(d, { showStatus }) {
|
|
280
|
+
return h('div', { class: 'records' }, d.records.map((r) => {
|
|
281
|
+
const host = hostLabel(r.name, d.domain);
|
|
282
|
+
const value = r.value;
|
|
283
|
+
return h('div', { class: 'rec' },
|
|
284
|
+
h('div', { class: 'rec-head' },
|
|
285
|
+
h('span', { class: 'type' }, r.type + (r.priority != null ? ` · priority ${r.priority}` : '')),
|
|
286
|
+
showStatus ? h('span', { class: `chip ${r.status}` }, CHIP[r.status] || r.status) : null),
|
|
287
|
+
h('div', { class: 'kv' },
|
|
288
|
+
h('span', { class: 'k' }, 'Host'), h('code', { title: r.name }, host), copyButton(host),
|
|
289
|
+
h('span', { class: 'k' }, 'Value'), h('code', {}, value), copyButton(value)),
|
|
290
|
+
);
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- views ----
|
|
295
|
+
const views = {
|
|
296
|
+
loading: () => h('div', { class: 'status' }, h('div', { class: 'spinner', 'aria-hidden': 'true' }), h('p', { class: 'muted' }, 'Loading…')),
|
|
297
|
+
|
|
298
|
+
fatal: () => h('div', { class: 'stack' }, h('h1', {}, 'Setup unavailable'), h('p', { class: 'muted' }, state.fatal)),
|
|
299
|
+
|
|
300
|
+
domain: () => {
|
|
301
|
+
const input = h('input', { type: 'text', id: 'domain', placeholder: 'yourcompany.com', autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Your domain' });
|
|
302
|
+
const submit = () => act(async () => setSession(await api('POST', '/v1/widget/domain', { domain: input.value.trim() })));
|
|
303
|
+
input.addEventListener('keydown', (e) => e.key === 'Enter' && submit());
|
|
304
|
+
return h('form', { class: 'stack', onsubmit: (e) => { e.preventDefault(); submit(); } },
|
|
305
|
+
h('div', {}, h('h1', {}, 'Send email from your domain'),
|
|
306
|
+
h('p', { class: 'muted' }, `${state.session.app_name} will send email as you@yourdomain. Which domain do you want to use?`)),
|
|
307
|
+
input, errorBanner(),
|
|
308
|
+
h('div', { class: 'row' }, h('button', { type: 'submit', class: 'btn primary', disabled: state.busy }, state.busy ? 'Setting up…' : 'Continue')));
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
choose: () => {
|
|
312
|
+
const d = domain();
|
|
313
|
+
const host = state.session.dns_host;
|
|
314
|
+
const option = (title, text, view) => h('button', { type: 'button', class: 'option', onclick: go(view) },
|
|
315
|
+
h('div', {}, h('b', {}, title), h('span', { class: 'muted' }, text)), h('span', { class: 'chev', 'aria-hidden': 'true' }, '›'));
|
|
316
|
+
const all = [
|
|
317
|
+
['cloudflare', option('Cloudflare', 'Connect once and we add the records for you', 'cloudflare')],
|
|
318
|
+
['route53', option('Amazon Route 53', 'Connect with an IAM access key', 'route53')],
|
|
319
|
+
['records', option(host?.name && !host.provider ? `${host.name} or another provider` : 'Another DNS provider', 'Copy a few records into your DNS settings', 'records')],
|
|
320
|
+
];
|
|
321
|
+
const suggested = host?.provider || (host?.name ? 'records' : null);
|
|
322
|
+
return h('div', { class: 'stack' },
|
|
323
|
+
h('div', {}, h('h1', {}, `Set up DNS for ${d.domain}`),
|
|
324
|
+
h('p', { class: 'muted' }, 'A few DNS records prove you own this domain so email you send isn\'t marked as spam.')),
|
|
325
|
+
suggested ? h('div', { class: 'hint stack' },
|
|
326
|
+
h('div', {}, h('b', {}, `Your DNS is hosted at ${host.name}.`),
|
|
327
|
+
h('div', { class: 'small' }, host.provider ? 'Connect it and the records are added automatically.' : 'Add the records in your account there.')),
|
|
328
|
+
h('div', { class: 'row' }, button(host.provider ? `Connect ${host.name}` : 'Show the records', go(suggested), true))) : null,
|
|
329
|
+
h('div', { class: 'stack' },
|
|
330
|
+
suggested ? h('div', { class: 'small muted' }, 'Or choose another way:') : null,
|
|
331
|
+
all.filter(([k]) => k !== suggested).map(([, el]) => el)),
|
|
332
|
+
errorBanner());
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
cloudflare: () => {
|
|
336
|
+
const d = domain();
|
|
337
|
+
const tokenInput = h('input', { type: 'password', id: 'cf', autocomplete: 'off', spellcheck: 'false', placeholder: 'Paste the API token' });
|
|
338
|
+
return h('form', { class: 'stack', onsubmit: (e) => { e.preventDefault(); act(async () => setSession(await api('POST', '/v1/widget/connect', { provider: 'cloudflare', cloudflare_api_token: tokenInput.value.trim() }), 'progress')); } },
|
|
339
|
+
h('div', {}, h('h1', {}, 'Connect Cloudflare'), h('p', { class: 'muted' }, `Create a token that can edit DNS for ${d.domain}:`)),
|
|
340
|
+
h('ol', { class: 'steps small' },
|
|
341
|
+
h('li', {}, 'Open ', h('a', { href: 'https://dash.cloudflare.com/profile/api-tokens', target: '_blank', rel: 'noopener noreferrer' }, 'Cloudflare → API Tokens'), ' and click Create Token.'),
|
|
342
|
+
h('li', {}, 'Use the “Edit zone DNS” template.'),
|
|
343
|
+
h('li', {}, `Under Zone Resources, pick ${d.domain}, then create the token.`)),
|
|
344
|
+
h('label', {}, 'API token', tokenInput),
|
|
345
|
+
h('p', { class: 'small muted' }, 'The token is encrypted and only used to manage these email records.'),
|
|
346
|
+
errorBanner(),
|
|
347
|
+
h('div', { class: 'row' },
|
|
348
|
+
h('button', { type: 'submit', class: 'btn primary', disabled: state.busy }, state.busy ? 'Checking access…' : 'Connect'),
|
|
349
|
+
button('Back', go('choose'))));
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
route53: () => {
|
|
353
|
+
const d = domain();
|
|
354
|
+
const keyId = h('input', { type: 'text', autocomplete: 'off', spellcheck: 'false', placeholder: 'AKIA…' });
|
|
355
|
+
const secret = h('input', { type: 'password', autocomplete: 'off', spellcheck: 'false' });
|
|
356
|
+
const policy = JSON.stringify({
|
|
357
|
+
Version: '2012-10-17',
|
|
358
|
+
Statement: [
|
|
359
|
+
{ Effect: 'Allow', Action: ['route53:ListHostedZonesByName'], Resource: '*' },
|
|
360
|
+
{ Effect: 'Allow', Action: ['route53:ListResourceRecordSets', 'route53:ChangeResourceRecordSets'], Resource: 'arn:aws:route53:::hostedzone/YOUR_ZONE_ID' },
|
|
361
|
+
],
|
|
362
|
+
}, null, 2);
|
|
363
|
+
return h('form', { class: 'stack', onsubmit: (e) => { e.preventDefault(); act(async () => setSession(await api('POST', '/v1/widget/connect', { provider: 'route53', aws_access_key_id: keyId.value.trim(), aws_secret_access_key: secret.value.trim() }), 'progress')); } },
|
|
364
|
+
h('div', {}, h('h1', {}, 'Connect Amazon Route 53'),
|
|
365
|
+
h('p', { class: 'muted' }, `Create an IAM user with this policy (replace YOUR_ZONE_ID with the hosted zone of ${d.domain}), then create an access key for it.`)),
|
|
366
|
+
h('div', { class: 'stack' }, h('pre', {}, policy), h('div', {}, copyButton(policy))),
|
|
367
|
+
h('label', {}, 'Access key ID', keyId),
|
|
368
|
+
h('label', {}, 'Secret access key', secret),
|
|
369
|
+
h('p', { class: 'small muted' }, 'Keys are encrypted and only used to manage these email records.'),
|
|
370
|
+
errorBanner(),
|
|
371
|
+
h('div', { class: 'row' },
|
|
372
|
+
h('button', { type: 'submit', class: 'btn primary', disabled: state.busy }, state.busy ? 'Checking access…' : 'Connect'),
|
|
373
|
+
button('Back', go('choose'))));
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
records: () => {
|
|
377
|
+
const d = domain();
|
|
378
|
+
const host = state.session.dns_host;
|
|
379
|
+
const proxyNote = host?.provider === 'cloudflare' ? ' Set each CNAME to “DNS only” (grey cloud).' : '';
|
|
380
|
+
return h('div', { class: 'stack' },
|
|
381
|
+
h('div', {}, h('h1', {}, 'Add these DNS records'),
|
|
382
|
+
h('p', { class: 'muted' }, `In ${host?.name ? 'your ' + host.name + ' account' : 'your DNS provider'}, add each record to ${d.domain}.${proxyNote}`)),
|
|
383
|
+
recordList(d, { showStatus: d.status !== 'pending' }),
|
|
384
|
+
h('p', { class: 'small muted' }, 'If your provider adds the domain automatically, enter only the Host part shown. Changes can take a few minutes to appear.'),
|
|
385
|
+
errorBanner(),
|
|
386
|
+
h('div', { class: 'row' },
|
|
387
|
+
button(state.busy ? 'Checking…' : 'I\'ve added them', () => act(async () => setSession(await api('POST', '/v1/widget/verify'), 'progress')), true),
|
|
388
|
+
button('Back', go('choose'))));
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
progress: () => {
|
|
392
|
+
const d = domain();
|
|
393
|
+
const manual = d.dns_provider === 'manual';
|
|
394
|
+
const changeMethod = h('button', { type: 'button', class: 'link', onclick: go('choose') }, 'Use a different setup method');
|
|
395
|
+
const checkNow = button('Check now', () => act(async () => setSession(await api('POST', '/v1/widget/verify'))));
|
|
396
|
+
|
|
397
|
+
if (d.status === 'conflict') {
|
|
398
|
+
return h('div', { class: 'stack' },
|
|
399
|
+
h('div', {}, h('h1', {}, 'Some existing records are in the way'),
|
|
400
|
+
h('p', { class: 'muted' }, `${d.domain} already has records with these names pointing somewhere else:`)),
|
|
401
|
+
h('div', { class: 'records' }, (d.conflicts || []).map((c) => h('div', { class: 'rec' },
|
|
402
|
+
h('div', { class: 'rec-head' }, h('span', { class: 'type' }, `${c.type} ${hostLabel(c.name, d.domain)}`), h('span', { class: 'chip conflict' }, 'Conflict')),
|
|
403
|
+
h('div', { class: 'small muted' }, c.reason)))),
|
|
404
|
+
h('p', { class: 'small muted' }, 'Replacing them points those names at your email setup instead. Records with other names are not touched.'),
|
|
405
|
+
errorBanner(),
|
|
406
|
+
h('div', { class: 'row' },
|
|
407
|
+
button(state.busy ? 'Replacing…' : 'Replace them', () => act(async () => setSession(await api('POST', '/v1/widget/resolve-conflicts'), 'progress')), true),
|
|
408
|
+
changeMethod));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (d.status === 'failed') {
|
|
412
|
+
return h('div', { class: 'stack' },
|
|
413
|
+
h('div', {}, h('h1', {}, 'Setup didn\'t finish'), h('div', { class: 'banner bad' }, d.error || 'Something went wrong.')),
|
|
414
|
+
manual ? recordList(d, { showStatus: true }) : null,
|
|
415
|
+
errorBanner(),
|
|
416
|
+
h('div', { class: 'row' }, button(state.busy ? 'Retrying…' : 'Try again', () => act(async () => setSession(await api('POST', '/v1/widget/verify'))), true), changeMethod));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const live = d.records.filter((r) => r.status === 'live').length;
|
|
420
|
+
const headline = d.status === 'verifying' ? 'Records found. Confirming…' : manual ? 'Looking for your DNS records…' : 'Adding DNS records…';
|
|
421
|
+
return h('div', { class: 'stack' },
|
|
422
|
+
h('div', { class: 'status' }, h('div', { class: 'spinner', 'aria-hidden': 'true' }),
|
|
423
|
+
h('div', {}, h('h1', {}, headline),
|
|
424
|
+
h('p', { class: 'muted small' }, `${live} of ${d.records.length} records found. This usually takes a few minutes. You can close this; we keep checking in the background.`))),
|
|
425
|
+
d.error ? h('div', { class: 'banner warn' }, d.error) : null,
|
|
426
|
+
recordList(d, { showStatus: true }),
|
|
427
|
+
errorBanner(),
|
|
428
|
+
h('div', { class: 'row' }, checkNow, changeMethod));
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
done: () => {
|
|
432
|
+
const d = domain();
|
|
433
|
+
return h('div', { class: 'stack' },
|
|
434
|
+
h('div', { class: 'done-icon', 'aria-hidden': 'true' }, checkIcon()),
|
|
435
|
+
h('div', {}, h('h1', {}, `${d.domain} is verified`),
|
|
436
|
+
h('p', { class: 'muted' }, `You're all set to send email from ${d.domain}.${d.inbound_hostname ? ` Replies to ${d.inbound_hostname} are set up too.` : ''}`)));
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
function checkIcon() {
|
|
441
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
442
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
443
|
+
const attrs = { width: 22, height: 22, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 3, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' };
|
|
444
|
+
for (const [k, v] of Object.entries(attrs)) svg.setAttribute(k, v);
|
|
445
|
+
const path = document.createElementNS(NS, 'path');
|
|
446
|
+
path.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5');
|
|
447
|
+
svg.append(path);
|
|
448
|
+
return svg;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function render() {
|
|
452
|
+
app.replaceChildren(state.fatal ? views.fatal() : views[state.view]());
|
|
453
|
+
if (state.focusNext && !state.busy) {
|
|
454
|
+
state.focusNext = false;
|
|
455
|
+
const target = app.querySelector('input') || app.querySelector('h1');
|
|
456
|
+
if (target?.tagName === 'H1') target.tabIndex = -1;
|
|
457
|
+
target?.focus({ preventScroll: true });
|
|
458
|
+
}
|
|
459
|
+
reportSize(false);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---- start ----
|
|
463
|
+
if (!CONFIG.valid || !token) {
|
|
464
|
+
state.fatal = 'This setup link has expired or is invalid. Close it and open the setup again to continue.';
|
|
465
|
+
post({ type: 'error', error: { type: 'session_expired', message: state.fatal } });
|
|
466
|
+
render();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
render();
|
|
470
|
+
api('GET', '/v1/widget/session')
|
|
471
|
+
.then((session) => {
|
|
472
|
+
setSession(session);
|
|
473
|
+
post({ type: 'ready', domain: session.domain });
|
|
474
|
+
})
|
|
475
|
+
.catch(handleError);
|
|
476
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) schedulePoll(); });
|
|
477
|
+
})();
|
|
478
|
+
</script>
|
|
479
|
+
</body>
|
|
480
|
+
</html>
|
package/web/widget.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*! dkimkit widget loader v1 */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict';
|
|
4
|
+
if (window.DKIMkit && window.DKIMkit.mount) return;
|
|
5
|
+
|
|
6
|
+
var script = document.currentScript;
|
|
7
|
+
var DEFAULT_ORIGIN = script && script.src ? new URL(script.src).origin : null;
|
|
8
|
+
|
|
9
|
+
function call(fn, arg) {
|
|
10
|
+
if (typeof fn === 'function') {
|
|
11
|
+
try { fn(arg); } catch (err) { setTimeout(function () { throw err; }); }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mounts the domain setup widget.
|
|
17
|
+
*
|
|
18
|
+
* DKIMkit.mount('#email-domain', {
|
|
19
|
+
* session: 'wss_…', // client_secret from POST /v1/widget-sessions
|
|
20
|
+
* onStatusChange: function (domain) {},
|
|
21
|
+
* onVerified: function (domain) {},
|
|
22
|
+
* onError: function (error) {},
|
|
23
|
+
* appearance: { accent: '#4f46e5', mode: 'light' (default) | 'dark' | 'auto', radius: 8 },
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
function mount(target, options) {
|
|
27
|
+
options = options || {};
|
|
28
|
+
var el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
29
|
+
if (!el) throw new Error('DKIMkit.mount: target element not found');
|
|
30
|
+
if (!options.session) throw new Error('DKIMkit.mount: options.session is required');
|
|
31
|
+
var origin = options.apiOrigin || DEFAULT_ORIGIN;
|
|
32
|
+
if (!origin) throw new Error('DKIMkit.mount: options.apiOrigin is required when widget.js is not loaded with a <script src>');
|
|
33
|
+
|
|
34
|
+
var params = new URLSearchParams({ session: options.session });
|
|
35
|
+
var appearance = options.appearance || {};
|
|
36
|
+
if (appearance.accent) params.set('accent', appearance.accent);
|
|
37
|
+
if (appearance.mode) params.set('mode', appearance.mode);
|
|
38
|
+
if (appearance.radius != null) params.set('radius', String(appearance.radius));
|
|
39
|
+
|
|
40
|
+
var iframe = document.createElement('iframe');
|
|
41
|
+
iframe.src = origin + '/widget/frame?' + params.toString();
|
|
42
|
+
iframe.title = options.title || 'Email domain setup';
|
|
43
|
+
iframe.setAttribute('allow', 'clipboard-write');
|
|
44
|
+
// Browsers may paint an opaque backdrop behind an iframe (e.g. when color schemes
|
|
45
|
+
// differ), so clip the frame to the widget's own corner radius rather than rely on
|
|
46
|
+
// transparency. Matching color-scheme still avoids the backdrop where it can.
|
|
47
|
+
var scheme = appearance.mode === 'dark' ? 'dark' : appearance.mode === 'auto' ? 'light dark' : 'light';
|
|
48
|
+
var radius = appearance.radius != null ? Math.max(0, Math.min(24, appearance.radius | 0)) : 10;
|
|
49
|
+
iframe.style.cssText = 'display:block;width:100%;height:360px;border:0;background:transparent;overflow:hidden;' +
|
|
50
|
+
'border-radius:' + radius + 'px;color-scheme:' + scheme + ';';
|
|
51
|
+
|
|
52
|
+
function onMessage(event) {
|
|
53
|
+
if (event.source !== iframe.contentWindow || event.origin !== origin) return;
|
|
54
|
+
var msg = event.data;
|
|
55
|
+
if (!msg || msg.source !== 'dkimkit-widget') return;
|
|
56
|
+
switch (msg.type) {
|
|
57
|
+
case 'resize':
|
|
58
|
+
iframe.style.height = Math.max(120, Math.min(4000, msg.height | 0)) + 'px';
|
|
59
|
+
break;
|
|
60
|
+
case 'ready':
|
|
61
|
+
call(options.onReady, msg.domain);
|
|
62
|
+
break;
|
|
63
|
+
case 'status':
|
|
64
|
+
call(options.onStatusChange, msg.domain);
|
|
65
|
+
if (msg.domain && msg.domain.status === 'verified') call(options.onVerified, msg.domain);
|
|
66
|
+
break;
|
|
67
|
+
case 'error':
|
|
68
|
+
call(options.onError, msg.error);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
window.addEventListener('message', onMessage);
|
|
74
|
+
// The frame only talks to a parent that has introduced itself, and checks
|
|
75
|
+
// that parent's origin against the account's allowed origins.
|
|
76
|
+
iframe.addEventListener('load', function () {
|
|
77
|
+
iframe.contentWindow.postMessage({ source: 'dkimkit-host', type: 'hello' }, origin);
|
|
78
|
+
});
|
|
79
|
+
el.appendChild(iframe);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
iframe: iframe,
|
|
83
|
+
unmount: function () {
|
|
84
|
+
window.removeEventListener('message', onMessage);
|
|
85
|
+
iframe.remove();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
window.DKIMkit = { mount: mount, version: 1 };
|
|
91
|
+
})();
|