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
package/web/index.html
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
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
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233b5bdb'/%3E%3Cpath d='M8 11h16v11H8z M8 12l8 6 8-6' fill='none' stroke='white' stroke-width='2.2' stroke-linejoin='round'/%3E%3C/svg%3E">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f6f7f9;
|
|
11
|
+
--surface: #ffffff;
|
|
12
|
+
--surface-2: #f1f3f6;
|
|
13
|
+
--border: #e2e5ea;
|
|
14
|
+
--text: #15181d;
|
|
15
|
+
--muted: #5d6573;
|
|
16
|
+
--accent: #3b5bdb;
|
|
17
|
+
--accent-hover: #3249b8;
|
|
18
|
+
--accent-soft: #e8edff;
|
|
19
|
+
--on-accent: #ffffff;
|
|
20
|
+
--ok: #1f8a4c;
|
|
21
|
+
--ok-soft: #e4f5ea;
|
|
22
|
+
--warn: #a35a00;
|
|
23
|
+
--warn-soft: #fdf1df;
|
|
24
|
+
--bad: #c4302b;
|
|
25
|
+
--bad-soft: #fdeaea;
|
|
26
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
27
|
+
--radius: 10px;
|
|
28
|
+
color-scheme: light;
|
|
29
|
+
}
|
|
30
|
+
@media (prefers-color-scheme: dark) {
|
|
31
|
+
:root:not([data-theme="light"]) {
|
|
32
|
+
--bg: #0f1115; --surface: #171a20; --surface-2: #1e222a; --border: #2a2f39;
|
|
33
|
+
--text: #e8eaee; --muted: #9aa3b2; --accent: #7b93ff; --accent-hover: #93a7ff; --accent-soft: #1f2645; --on-accent: #0b0f1f;
|
|
34
|
+
--ok: #4cc27e; --ok-soft: #14301f; --warn: #f0a640; --warn-soft: #33260f; --bad: #ff6b65; --bad-soft: #3a1717;
|
|
35
|
+
color-scheme: dark;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
:root[data-theme="dark"] {
|
|
39
|
+
--bg: #0f1115; --surface: #171a20; --surface-2: #1e222a; --border: #2a2f39;
|
|
40
|
+
--text: #e8eaee; --muted: #9aa3b2; --accent: #7b93ff; --accent-hover: #93a7ff; --accent-soft: #1f2645; --on-accent: #0b0f1f;
|
|
41
|
+
--ok: #4cc27e; --ok-soft: #14301f; --warn: #f0a640; --warn-soft: #33260f; --bad: #ff6b65; --bad-soft: #3a1717;
|
|
42
|
+
color-scheme: dark;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
47
|
+
header { border-bottom: 1px solid var(--border); background: var(--surface); }
|
|
48
|
+
.bar { max-width: 820px; margin: 0 auto; padding: 14px 16px; display: flex; align-items: center; gap: 10px; }
|
|
49
|
+
.mark { width: 26px; height: 26px; border-radius: 7px; background: var(--accent); display: grid; place-items: center; color: var(--on-accent); }
|
|
50
|
+
.bar h1 { font-size: 15px; font-weight: 600; margin: 0; }
|
|
51
|
+
.bar .meta { margin-left: auto; color: var(--muted); font-size: 13px; }
|
|
52
|
+
main { max-width: 820px; margin: 0 auto; padding: 24px 16px 64px; display: grid; gap: 16px; }
|
|
53
|
+
|
|
54
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
|
|
55
|
+
.card h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 14px; font-weight: 600; }
|
|
56
|
+
.grid { display: grid; gap: 14px; }
|
|
57
|
+
.two { grid-template-columns: 1fr 1fr; }
|
|
58
|
+
@media (max-width: 600px) { .two { grid-template-columns: 1fr; } }
|
|
59
|
+
|
|
60
|
+
label.field { display: grid; gap: 6px; font-size: 13px; font-weight: 500; }
|
|
61
|
+
label.field .hint { font-weight: 400; color: var(--muted); }
|
|
62
|
+
input[type=text], input[type=password], input[type=url] {
|
|
63
|
+
width: 100%; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border);
|
|
64
|
+
border-radius: 8px; padding: 9px 11px; outline: none;
|
|
65
|
+
}
|
|
66
|
+
input:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
67
|
+
input.big { font-size: 17px; padding: 11px 13px; }
|
|
68
|
+
.check { display: flex; gap: 8px; align-items: center; font-size: 14px; cursor: pointer; }
|
|
69
|
+
.check input { width: 16px; height: 16px; accent-color: var(--accent); }
|
|
70
|
+
|
|
71
|
+
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; list-style: none; }
|
|
72
|
+
details > summary::before { content: "▸ "; }
|
|
73
|
+
details[open] > summary::before { content: "▾ "; }
|
|
74
|
+
details[open] > .grid { margin-top: 12px; }
|
|
75
|
+
|
|
76
|
+
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; flex-wrap: wrap; }
|
|
77
|
+
.seg button { font: inherit; font-size: 14px; border: 0; background: none; color: var(--muted); padding: 7px 14px; border-radius: 6px; cursor: pointer; }
|
|
78
|
+
.seg button[aria-pressed=true] { background: var(--surface); color: var(--text); box-shadow: 0 1px 2px rgb(0 0 0 / .12); font-weight: 500; }
|
|
79
|
+
.provider-panel { margin-top: 14px; }
|
|
80
|
+
|
|
81
|
+
.note { font-size: 13px; color: var(--muted); margin: 0; }
|
|
82
|
+
.envtag { display: inline-block; font-size: 11px; font-weight: 600; color: var(--ok); background: var(--ok-soft); padding: 1px 7px; border-radius: 99px; margin-left: 6px; vertical-align: 1px; }
|
|
83
|
+
|
|
84
|
+
.actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
85
|
+
.btn { font: inherit; font-size: 14px; font-weight: 500; border-radius: 8px; padding: 9px 16px; cursor: pointer; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
|
|
86
|
+
.btn:hover { background: var(--surface-2); }
|
|
87
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
88
|
+
.btn.primary:hover { background: var(--accent-hover); }
|
|
89
|
+
.btn:disabled { opacity: .55; cursor: default; }
|
|
90
|
+
.btn.small { padding: 3px 9px; font-size: 12px; }
|
|
91
|
+
|
|
92
|
+
.banner { border-radius: 8px; padding: 12px 14px; font-size: 14px; border: 1px solid transparent; }
|
|
93
|
+
.banner.bad { background: var(--bad-soft); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 25%, transparent); }
|
|
94
|
+
.banner.warn { background: var(--warn-soft); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
|
|
95
|
+
.banner.ok { background: var(--ok-soft); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
|
|
96
|
+
.banner strong { display: block; margin-bottom: 2px; }
|
|
97
|
+
.banner p { margin: 4px 0 0; color: var(--text); opacity: .85; }
|
|
98
|
+
|
|
99
|
+
.domainline { display: flex; gap: 8px; flex-wrap: wrap; align-items: baseline; margin-bottom: 14px; }
|
|
100
|
+
.domainline b { font-size: 17px; }
|
|
101
|
+
.domainline span { color: var(--muted); font-size: 13px; }
|
|
102
|
+
|
|
103
|
+
.records { display: grid; gap: 8px; }
|
|
104
|
+
.rec { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: grid; grid-template-columns: 72px 1fr; gap: 4px 12px; align-items: center; }
|
|
105
|
+
.rec .badge { justify-self: start; }
|
|
106
|
+
.rec.conflict { border-color: color-mix(in srgb, var(--bad) 45%, var(--border)); background: var(--bad-soft); }
|
|
107
|
+
.badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 99px; text-align: center; white-space: nowrap; }
|
|
108
|
+
.badge.create { background: var(--accent-soft); color: var(--accent); }
|
|
109
|
+
.badge.update { background: var(--warn-soft); color: var(--warn); }
|
|
110
|
+
.badge.noop { background: var(--surface-2); color: var(--muted); }
|
|
111
|
+
.badge.conflict { background: var(--bad); color: var(--on-accent); }
|
|
112
|
+
.rec .kv { display: grid; grid-template-columns: 52px 1fr auto; gap: 4px 10px; align-items: center; min-width: 0; }
|
|
113
|
+
.rec .k { font-size: 12px; color: var(--muted); }
|
|
114
|
+
.rec code { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere; }
|
|
115
|
+
.rec .reason { grid-column: 2; font-size: 13px; color: var(--muted); }
|
|
116
|
+
.rec.conflict .reason { color: var(--bad); }
|
|
117
|
+
.rectype { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
118
|
+
|
|
119
|
+
ol.steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
|
120
|
+
ol.steps li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; padding: 9px 0; }
|
|
121
|
+
.icon { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border); margin-top: 1px; display: grid; place-items: center; font-size: 12px; font-weight: 700; }
|
|
122
|
+
li[data-state=running] .icon { border-color: var(--accent); border-top-color: transparent; animation: spin .8s linear infinite; }
|
|
123
|
+
li[data-state=done] .icon { background: var(--ok); border-color: var(--ok); color: var(--on-accent); }
|
|
124
|
+
li[data-state=done] .icon::after { content: "✓"; }
|
|
125
|
+
li[data-state=fail] .icon { background: var(--bad); border-color: var(--bad); color: var(--on-accent); }
|
|
126
|
+
li[data-state=fail] .icon::after { content: "!"; }
|
|
127
|
+
li[data-state=skip] { opacity: .5; }
|
|
128
|
+
li[data-state=pending] .title { color: var(--muted); }
|
|
129
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
130
|
+
@media (prefers-reduced-motion: reduce) { li[data-state=running] .icon { animation: none; border-top-color: var(--accent); } }
|
|
131
|
+
.title { font-weight: 500; }
|
|
132
|
+
.detail { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; }
|
|
133
|
+
.progress { height: 6px; background: var(--surface-2); border-radius: 99px; overflow: hidden; margin-top: 6px; max-width: 320px; }
|
|
134
|
+
.progress > div { height: 100%; background: var(--accent); width: 0; transition: width .3s; }
|
|
135
|
+
|
|
136
|
+
.status-row { display: flex; gap: 10px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
|
137
|
+
.status-row:last-child { border-bottom: 0; }
|
|
138
|
+
.status-row .ok { color: var(--ok); } .status-row .bad { color: var(--bad); }
|
|
139
|
+
@media (max-width: 600px) {
|
|
140
|
+
.rec { grid-template-columns: 1fr; gap: 8px; }
|
|
141
|
+
.rec .reason { grid-column: 1; }
|
|
142
|
+
.rec .kv { grid-template-columns: 44px 1fr auto; }
|
|
143
|
+
.seg { display: flex; }
|
|
144
|
+
.seg button { flex: 1; padding: 7px 6px; font-size: 13px; }
|
|
145
|
+
}
|
|
146
|
+
[hidden] { display: none !important; }
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
<header>
|
|
151
|
+
<div class="bar">
|
|
152
|
+
<div class="mark" aria-hidden="true">
|
|
153
|
+
<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>
|
|
154
|
+
</div>
|
|
155
|
+
<h1>DKIMkit</h1>
|
|
156
|
+
<span class="meta" id="version">local</span>
|
|
157
|
+
</div>
|
|
158
|
+
</header>
|
|
159
|
+
|
|
160
|
+
<main>
|
|
161
|
+
<form id="form" class="grid" autocomplete="off" novalidate>
|
|
162
|
+
<section class="card grid">
|
|
163
|
+
<h2>Domain</h2>
|
|
164
|
+
<label class="field">Domain to send from
|
|
165
|
+
<input class="big" type="text" id="domain" placeholder="example.com" spellcheck="false" required>
|
|
166
|
+
</label>
|
|
167
|
+
<label class="check" id="inboundToggle"><input type="checkbox" id="inboundOn"> Also receive email</label>
|
|
168
|
+
<p class="note" id="inboundUnsupported" hidden>Receiving email isn't supported with Amazon SES yet.</p>
|
|
169
|
+
<div id="inboundFields" class="grid two" hidden>
|
|
170
|
+
<label class="field">Webhook URL <span class="hint">Each received email is POSTed here</span>
|
|
171
|
+
<input type="url" id="inboundUrl" placeholder="https://api.example.com/webhooks/inbound" spellcheck="false">
|
|
172
|
+
</label>
|
|
173
|
+
<label class="field">Receiving hostname <span class="hint">MX record goes here</span>
|
|
174
|
+
<input type="text" id="inboundHost" placeholder="parse.example.com" spellcheck="false">
|
|
175
|
+
</label>
|
|
176
|
+
<label class="check"><input type="checkbox" id="spamCheck"> Run spam check</label>
|
|
177
|
+
<label class="check"><input type="checkbox" id="sendRaw"> Send raw MIME</label>
|
|
178
|
+
</div>
|
|
179
|
+
<details>
|
|
180
|
+
<summary>Advanced</summary>
|
|
181
|
+
<div class="grid two">
|
|
182
|
+
<label class="field">Return-path / MAIL FROM label <span class="hint">Blank uses the provider's default</span>
|
|
183
|
+
<input type="text" id="subdomain" placeholder="em" spellcheck="false">
|
|
184
|
+
</label>
|
|
185
|
+
<label class="field" data-only="sendgrid">SendGrid subuser <span class="hint">Act on behalf of</span>
|
|
186
|
+
<input type="text" id="onBehalfOf" spellcheck="false">
|
|
187
|
+
</label>
|
|
188
|
+
</div>
|
|
189
|
+
</details>
|
|
190
|
+
</section>
|
|
191
|
+
|
|
192
|
+
<section class="card grid">
|
|
193
|
+
<h2>Connections</h2>
|
|
194
|
+
<div>
|
|
195
|
+
<div class="field" style="font-size:13px;font-weight:500;margin-bottom:8px">Email provider</div>
|
|
196
|
+
<div class="seg" role="group" aria-label="Email provider">
|
|
197
|
+
<button type="button" data-email="sendgrid">SendGrid</button>
|
|
198
|
+
<button type="button" data-email="postmark">Postmark</button>
|
|
199
|
+
<button type="button" data-email="mailgun">Mailgun</button>
|
|
200
|
+
<button type="button" data-email="ses">Amazon SES</button>
|
|
201
|
+
</div>
|
|
202
|
+
<div class="provider-panel grid" data-email-panel="sendgrid">
|
|
203
|
+
<label class="field"><span>SendGrid API key <span class="envtag" id="sgEnv" hidden>from env</span></span>
|
|
204
|
+
<input type="password" id="sendgridApiKey" placeholder="SG.…" spellcheck="false">
|
|
205
|
+
</label>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="provider-panel grid two" data-email-panel="postmark" hidden>
|
|
208
|
+
<label class="field"><span>Account API token <span class="envtag" id="pmEnv" hidden>from env</span></span>
|
|
209
|
+
<input type="password" id="postmarkAccountToken" spellcheck="false">
|
|
210
|
+
</label>
|
|
211
|
+
<label class="field">Inbound server ID <span class="hint">Only to receive email</span>
|
|
212
|
+
<input type="text" id="postmarkInboundServerId" spellcheck="false">
|
|
213
|
+
</label>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="provider-panel grid two" data-email-panel="mailgun" hidden>
|
|
216
|
+
<label class="field"><span>API key <span class="envtag" id="mgEnv" hidden>from env</span></span>
|
|
217
|
+
<input type="password" id="mailgunApiKey" spellcheck="false">
|
|
218
|
+
</label>
|
|
219
|
+
<label class="field">Region <span class="hint">us or eu</span>
|
|
220
|
+
<input type="text" id="mailgunRegion" placeholder="us" spellcheck="false">
|
|
221
|
+
</label>
|
|
222
|
+
<p class="note" style="grid-column:1/-1">Mailgun recommends a subdomain for sending, like <code>mg.example.com</code>.</p>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="provider-panel grid" data-email-panel="ses" hidden>
|
|
225
|
+
<label class="field">AWS region <span class="hint" id="sesRegionHint">e.g. us-east-1</span>
|
|
226
|
+
<input type="text" id="sesRegion" placeholder="us-east-1" spellcheck="false">
|
|
227
|
+
</label>
|
|
228
|
+
<p class="note">Uses the AWS keys entered under Route 53 below, or your AWS environment (AWS_ACCESS_KEY_ID… or ~/.aws/credentials).</p>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div>
|
|
233
|
+
<div class="field" style="font-size:13px;font-weight:500;margin-bottom:8px">DNS provider</div>
|
|
234
|
+
<div class="seg" role="group" aria-label="DNS provider">
|
|
235
|
+
<button type="button" data-dns="cloudflare">Cloudflare</button>
|
|
236
|
+
<button type="button" data-dns="route53">Route 53</button>
|
|
237
|
+
<button type="button" data-dns="manual">Other host</button>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div class="provider-panel grid" data-panel="cloudflare">
|
|
241
|
+
<label class="field"><span>API token <span class="envtag" id="cfEnv" hidden>from env</span></span>
|
|
242
|
+
<input type="password" id="cloudflareApiToken" spellcheck="false">
|
|
243
|
+
</label>
|
|
244
|
+
<p class="note">Needs <b>Zone → Read</b> and <b>DNS → Edit</b> for this domain's zone.</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div class="provider-panel grid" data-panel="route53" hidden>
|
|
248
|
+
<p class="note" id="awsEnvNote" hidden>AWS credentials found in your environment or ~/.aws/credentials. Leave these blank to use them.</p>
|
|
249
|
+
<div class="grid two">
|
|
250
|
+
<label class="field">Access key ID <input type="text" id="awsAccessKeyId" spellcheck="false"></label>
|
|
251
|
+
<label class="field">Secret access key <input type="password" id="awsSecretAccessKey" spellcheck="false"></label>
|
|
252
|
+
</div>
|
|
253
|
+
<details>
|
|
254
|
+
<summary>Session token, profile, hosted zone</summary>
|
|
255
|
+
<div class="grid two">
|
|
256
|
+
<label class="field">Session token <input type="password" id="awsSessionToken" spellcheck="false"></label>
|
|
257
|
+
<label class="field">Profile <span class="hint">From ~/.aws/credentials</span><input type="text" id="awsProfile" spellcheck="false"></label>
|
|
258
|
+
<label class="field">Hosted zone ID <span class="hint">Blank to look up by name</span><input type="text" id="route53HostedZoneId" spellcheck="false"></label>
|
|
259
|
+
</div>
|
|
260
|
+
</details>
|
|
261
|
+
<p class="note">Needs <code>route53:ListHostedZonesByName</code>, <code>ListResourceRecordSets</code> and <code>ChangeResourceRecordSets</code>.</p>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div class="provider-panel" data-panel="manual" hidden>
|
|
265
|
+
<p class="note">You'll get the exact records to add at your registrar. This page then watches DNS and verifies with your email provider once they're live.</p>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</section>
|
|
269
|
+
|
|
270
|
+
<div class="actions">
|
|
271
|
+
<button class="btn primary" type="submit" id="previewBtn">Preview DNS changes</button>
|
|
272
|
+
<button class="btn" type="button" id="statusBtn">Check status</button>
|
|
273
|
+
</div>
|
|
274
|
+
<div id="formError" class="banner bad" hidden></div>
|
|
275
|
+
</form>
|
|
276
|
+
|
|
277
|
+
<section class="card" id="planCard" hidden>
|
|
278
|
+
<h2>Review</h2>
|
|
279
|
+
<div class="domainline" id="domainLine"></div>
|
|
280
|
+
<div class="records" id="records"></div>
|
|
281
|
+
<div id="conflictBox" class="grid" style="margin-top:14px" hidden>
|
|
282
|
+
<div class="banner bad"><strong>Some records already point somewhere else.</strong>
|
|
283
|
+
<p>Nothing has been changed. Fix them at your DNS host, or replace them (only CNAME/MX records at these exact names are touched).</p></div>
|
|
284
|
+
<label class="check"><input type="checkbox" id="overwrite"> Replace the conflicting records</label>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="actions" style="margin-top:16px">
|
|
287
|
+
<button class="btn primary" id="applyBtn">Apply & verify</button>
|
|
288
|
+
<button class="btn" id="replanBtn" hidden>Preview again</button>
|
|
289
|
+
<span class="note" id="planNote"></span>
|
|
290
|
+
</div>
|
|
291
|
+
</section>
|
|
292
|
+
|
|
293
|
+
<section class="card" id="progressCard" hidden>
|
|
294
|
+
<h2>Progress</h2>
|
|
295
|
+
<ol class="steps" id="steps"></ol>
|
|
296
|
+
<div id="resultBanner" class="banner" style="margin-top:14px" hidden></div>
|
|
297
|
+
<div class="actions" style="margin-top:14px">
|
|
298
|
+
<button class="btn" id="cancelBtn">Cancel</button>
|
|
299
|
+
<button class="btn primary" id="retryBtn" hidden>Run again</button>
|
|
300
|
+
</div>
|
|
301
|
+
</section>
|
|
302
|
+
|
|
303
|
+
<section class="card" id="statusCard" hidden>
|
|
304
|
+
<h2>Provider status</h2>
|
|
305
|
+
<div id="statusBody"></div>
|
|
306
|
+
</section>
|
|
307
|
+
</main>
|
|
308
|
+
|
|
309
|
+
<script>
|
|
310
|
+
(() => {
|
|
311
|
+
const $ = (id) => document.getElementById(id);
|
|
312
|
+
const STORE_KEY = 'dkimkit:form';
|
|
313
|
+
// Saved before the rename; read once, then moved to STORE_KEY on the next save.
|
|
314
|
+
const LEGACY_STORE_KEY = 'sds:form';
|
|
315
|
+
const STORED_FIELDS = ['domain', 'subdomain', 'onBehalfOf', 'inboundUrl', 'inboundHost', 'awsProfile', 'route53HostedZoneId', 'postmarkInboundServerId', 'mailgunRegion', 'sesRegion'];
|
|
316
|
+
const EMAIL_LABELS = { sendgrid: 'SendGrid', postmark: 'Postmark', mailgun: 'Mailgun', ses: 'Amazon SES' };
|
|
317
|
+
let email = 'sendgrid';
|
|
318
|
+
const providerLabel = () => EMAIL_LABELS[email];
|
|
319
|
+
const STORED_CHECKS = ['inboundOn', 'spamCheck', 'sendRaw'];
|
|
320
|
+
let dns = 'cloudflare';
|
|
321
|
+
let lastPlan = null;
|
|
322
|
+
let running = null;
|
|
323
|
+
|
|
324
|
+
function h(tag, attrs = {}, ...children) {
|
|
325
|
+
const el = document.createElement(tag);
|
|
326
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
327
|
+
if (k === 'class') el.className = v;
|
|
328
|
+
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
|
329
|
+
else if (v !== false && v != null) el.setAttribute(k, v === true ? '' : v);
|
|
330
|
+
}
|
|
331
|
+
for (const c of children.flat()) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
|
332
|
+
return el;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- form state ----
|
|
336
|
+
function setDns(name) {
|
|
337
|
+
dns = name;
|
|
338
|
+
document.querySelectorAll('[data-dns]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.dns === name)));
|
|
339
|
+
document.querySelectorAll('[data-panel]').forEach((p) => (p.hidden = p.dataset.panel !== name));
|
|
340
|
+
save();
|
|
341
|
+
}
|
|
342
|
+
document.querySelectorAll('[data-dns]').forEach((b) => b.addEventListener('click', () => setDns(b.dataset.dns)));
|
|
343
|
+
|
|
344
|
+
function setEmail(name) {
|
|
345
|
+
email = name;
|
|
346
|
+
document.querySelectorAll('[data-email]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.email === name)));
|
|
347
|
+
document.querySelectorAll('[data-email-panel]').forEach((p) => (p.hidden = p.dataset.emailPanel !== name));
|
|
348
|
+
document.querySelectorAll('[data-only]').forEach((el) => (el.hidden = el.dataset.only !== name));
|
|
349
|
+
// SES can't receive mail yet.
|
|
350
|
+
const inboundOk = name !== 'ses';
|
|
351
|
+
$('inboundToggle').hidden = !inboundOk;
|
|
352
|
+
$('inboundUnsupported').hidden = inboundOk;
|
|
353
|
+
if (!inboundOk) $('inboundOn').checked = false;
|
|
354
|
+
$('inboundFields').hidden = !$('inboundOn').checked;
|
|
355
|
+
updateInboundPlaceholder();
|
|
356
|
+
save();
|
|
357
|
+
}
|
|
358
|
+
document.querySelectorAll('[data-email]').forEach((b) => b.addEventListener('click', () => setEmail(b.dataset.email)));
|
|
359
|
+
|
|
360
|
+
// Mailgun receives on the sending domain itself; the others default to parse.<domain>.
|
|
361
|
+
function updateInboundPlaceholder() {
|
|
362
|
+
const d = $('domain').value.trim() || 'example.com';
|
|
363
|
+
$('inboundHost').placeholder = email === 'mailgun' ? d : `parse.${d}`;
|
|
364
|
+
}
|
|
365
|
+
$('inboundOn').addEventListener('change', () => { $('inboundFields').hidden = !$('inboundOn').checked; });
|
|
366
|
+
$('domain').addEventListener('input', updateInboundPlaceholder);
|
|
367
|
+
|
|
368
|
+
function save() {
|
|
369
|
+
try {
|
|
370
|
+
const data = { dns, email };
|
|
371
|
+
STORED_FIELDS.forEach((f) => (data[f] = $(f).value));
|
|
372
|
+
STORED_CHECKS.forEach((f) => (data[f] = $(f).checked));
|
|
373
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(data));
|
|
374
|
+
localStorage.removeItem(LEGACY_STORE_KEY);
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
function restore() {
|
|
378
|
+
try {
|
|
379
|
+
const data = JSON.parse(localStorage.getItem(STORE_KEY) || localStorage.getItem(LEGACY_STORE_KEY) || 'null');
|
|
380
|
+
if (!data) return;
|
|
381
|
+
STORED_FIELDS.forEach((f) => { if (typeof data[f] === 'string') $(f).value = data[f]; });
|
|
382
|
+
STORED_CHECKS.forEach((f) => { $(f).checked = Boolean(data[f]); });
|
|
383
|
+
if (data.dns) dns = data.dns;
|
|
384
|
+
if (data.email && EMAIL_LABELS[data.email]) email = data.email;
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
$('form').addEventListener('input', save);
|
|
388
|
+
|
|
389
|
+
function buildRequest() {
|
|
390
|
+
const v = (id) => $(id).value.trim();
|
|
391
|
+
return {
|
|
392
|
+
domain: v('domain'),
|
|
393
|
+
subdomain: v('subdomain'),
|
|
394
|
+
onBehalfOf: email === 'sendgrid' ? v('onBehalfOf') : '',
|
|
395
|
+
dns,
|
|
396
|
+
emailProvider: email,
|
|
397
|
+
overwrite: $('overwrite').checked,
|
|
398
|
+
credentials: {
|
|
399
|
+
sendgridApiKey: v('sendgridApiKey'),
|
|
400
|
+
postmarkAccountToken: v('postmarkAccountToken'),
|
|
401
|
+
postmarkInboundServerId: v('postmarkInboundServerId'),
|
|
402
|
+
mailgunApiKey: v('mailgunApiKey'),
|
|
403
|
+
mailgunRegion: v('mailgunRegion'),
|
|
404
|
+
sesRegion: v('sesRegion'),
|
|
405
|
+
cloudflareApiToken: v('cloudflareApiToken'),
|
|
406
|
+
awsAccessKeyId: v('awsAccessKeyId'),
|
|
407
|
+
awsSecretAccessKey: v('awsSecretAccessKey'),
|
|
408
|
+
awsSessionToken: v('awsSessionToken'),
|
|
409
|
+
awsProfile: v('awsProfile'),
|
|
410
|
+
route53HostedZoneId: v('route53HostedZoneId'),
|
|
411
|
+
},
|
|
412
|
+
inbound: $('inboundOn').checked
|
|
413
|
+
? { url: v('inboundUrl'), hostname: v('inboundHost'), spamCheck: $('spamCheck').checked, sendRaw: $('sendRaw').checked }
|
|
414
|
+
: undefined,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---- API ----
|
|
419
|
+
async function api(path, body) {
|
|
420
|
+
const res = await fetch(path, {
|
|
421
|
+
method: body ? 'POST' : 'GET',
|
|
422
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
423
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
424
|
+
});
|
|
425
|
+
const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
426
|
+
if (!res.ok) throw data;
|
|
427
|
+
return data;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function showError(target, err) {
|
|
431
|
+
target.replaceChildren(...[
|
|
432
|
+
h('strong', {}, err.error || err.message || 'Something went wrong.'),
|
|
433
|
+
err.hint ? h('p', {}, err.hint) : null,
|
|
434
|
+
].filter(Boolean));
|
|
435
|
+
target.className = 'banner bad';
|
|
436
|
+
target.hidden = false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function busy(button, label, fn) {
|
|
440
|
+
const original = button.textContent;
|
|
441
|
+
button.disabled = true;
|
|
442
|
+
button.textContent = label;
|
|
443
|
+
try { return await fn(); } finally { button.disabled = false; button.textContent = original; }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---- preview ----
|
|
447
|
+
async function preview() {
|
|
448
|
+
$('formError').hidden = true;
|
|
449
|
+
$('statusCard').hidden = true;
|
|
450
|
+
await busy($('previewBtn'), 'Checking…', async () => {
|
|
451
|
+
try {
|
|
452
|
+
lastPlan = await api('/api/plan', buildRequest());
|
|
453
|
+
renderPlan(lastPlan);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
showError($('formError'), err);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function copyButton(text) {
|
|
461
|
+
return h('button', {
|
|
462
|
+
class: 'btn small', type: 'button', title: 'Copy',
|
|
463
|
+
onclick: async (e) => {
|
|
464
|
+
try { await navigator.clipboard.writeText(text); e.target.textContent = 'Copied'; setTimeout(() => (e.target.textContent = 'Copy'), 1200); } catch {}
|
|
465
|
+
},
|
|
466
|
+
}, 'Copy');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const ACTION_LABEL = { create: 'Add', update: 'Update', noop: 'Exists', conflict: 'Conflict' };
|
|
470
|
+
|
|
471
|
+
function renderPlan(plan) {
|
|
472
|
+
const manual = dns === 'manual';
|
|
473
|
+
const d = plan.domain;
|
|
474
|
+
$('domainLine').replaceChildren(
|
|
475
|
+
h('b', {}, d.domain),
|
|
476
|
+
h('span', {}, `${providerLabel()} domain #${d.id} · ${d.created ? 'just created' : 'already existed'}${d.valid ? ' · verified' : ''}`),
|
|
477
|
+
);
|
|
478
|
+
$('records').replaceChildren(...plan.changes.map((c) => {
|
|
479
|
+
const r = c.record;
|
|
480
|
+
const value = r.priority != null ? `${r.priority} ${r.value}` : r.value;
|
|
481
|
+
return h('div', { class: `rec ${c.action}` },
|
|
482
|
+
h('span', { class: `badge ${manual ? 'create' : c.action}` }, manual ? r.type : ACTION_LABEL[c.action]),
|
|
483
|
+
h('div', { class: 'kv' },
|
|
484
|
+
h('span', { class: 'k' }, manual ? 'Name' : r.type), h('code', {}, r.name), copyButton(r.name),
|
|
485
|
+
h('span', { class: 'k' }, 'Value'), h('code', {}, value), copyButton(value),
|
|
486
|
+
),
|
|
487
|
+
c.reason ? h('span', { class: 'reason' }, c.reason) : null,
|
|
488
|
+
r.note && c.action === 'create' ? h('span', { class: 'reason' }, r.note) : null,
|
|
489
|
+
);
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
const writes = plan.changes.filter((c) => c.action === 'create' || c.action === 'update').length;
|
|
493
|
+
$('conflictBox').hidden = !plan.conflicts;
|
|
494
|
+
$('replanBtn').hidden = !plan.conflicts;
|
|
495
|
+
$('applyBtn').disabled = plan.conflicts;
|
|
496
|
+
$('applyBtn').textContent = manual ? "I've added these — verify" : writes ? `Apply ${writes} change${writes > 1 ? 's' : ''} & verify` : 'Verify';
|
|
497
|
+
$('planNote').textContent = manual
|
|
498
|
+
? 'Add each record at your DNS host. Turn off any proxy/CDN on the CNAMEs.'
|
|
499
|
+
: writes ? `Writes to ${dns === 'route53' ? 'Route 53' : 'Cloudflare'}. Existing records are kept.` : 'DNS is already up to date.';
|
|
500
|
+
$('planCard').hidden = false;
|
|
501
|
+
$('progressCard').hidden = true;
|
|
502
|
+
$('planCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- apply ----
|
|
506
|
+
const STEP_DEFS = [
|
|
507
|
+
['domain', 'Email provider domain'],
|
|
508
|
+
['dns', 'Write DNS records'],
|
|
509
|
+
['propagation', 'DNS propagation'],
|
|
510
|
+
['validation', 'Provider verification'],
|
|
511
|
+
['inbound', 'Inbound parse'],
|
|
512
|
+
];
|
|
513
|
+
const steps = {};
|
|
514
|
+
|
|
515
|
+
function resetSteps(withInbound) {
|
|
516
|
+
$('steps').replaceChildren(...STEP_DEFS.map(([id, title]) => {
|
|
517
|
+
const detail = h('div', { class: 'detail' });
|
|
518
|
+
const li = h('li', { 'data-state': id === 'inbound' && !withInbound ? 'skip' : 'pending' },
|
|
519
|
+
h('span', { class: 'icon', 'aria-hidden': 'true' }),
|
|
520
|
+
h('div', {}, h('div', { class: 'title' }, title), detail));
|
|
521
|
+
steps[id] = { li, detail };
|
|
522
|
+
return li;
|
|
523
|
+
}));
|
|
524
|
+
if (!withInbound) steps.inbound.detail.textContent = 'Not enabled';
|
|
525
|
+
}
|
|
526
|
+
function step(id, state, detail) {
|
|
527
|
+
const s = steps[id];
|
|
528
|
+
if (!s || s.li.dataset.state === 'skip') return;
|
|
529
|
+
s.li.dataset.state = state;
|
|
530
|
+
if (detail !== undefined) s.detail.replaceChildren(...[].concat(detail));
|
|
531
|
+
}
|
|
532
|
+
const stateOf = (id) => steps[id]?.li.dataset.state;
|
|
533
|
+
|
|
534
|
+
function onEvent(e) {
|
|
535
|
+
switch (e.type) {
|
|
536
|
+
case 'domain':
|
|
537
|
+
step('domain', 'done', `${e.auth.created ? 'Created' : 'Found'} #${e.auth.id}`);
|
|
538
|
+
step('dns', 'running', dns === 'manual' ? 'Waiting for your records' : 'Writing…');
|
|
539
|
+
break;
|
|
540
|
+
case 'applied':
|
|
541
|
+
step('dns', 'done', `${e.changes.length} record${e.changes.length > 1 ? 's' : ''} written`);
|
|
542
|
+
step('propagation', 'running', 'Checking authoritative nameservers…');
|
|
543
|
+
break;
|
|
544
|
+
case 'propagation': {
|
|
545
|
+
if (stateOf('dns') !== 'done') step('dns', 'done', dns === 'manual' ? 'Added at your DNS host' : 'Already up to date');
|
|
546
|
+
const done = e.total - e.pending.length;
|
|
547
|
+
if (e.pending.length === 0) { step('propagation', 'done', `All ${e.total} records live`); step('validation', 'running', `Asking ${providerLabel()}…`); break; }
|
|
548
|
+
const bar = h('div', { class: 'progress' }, h('div', { style: `width:${(done / e.total) * 100}%` }));
|
|
549
|
+
step('propagation', 'running', [`${done} of ${e.total} visible · ${Math.round(e.elapsedMs / 1000)}s`, bar]);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
case 'validation':
|
|
553
|
+
if (e.result.valid) step('validation', 'done', 'Verified');
|
|
554
|
+
else step('validation', 'running', `Attempt ${e.attempt}: ${providerLabel()} doesn't see them yet, retrying…`);
|
|
555
|
+
if (e.result.valid) step('inbound', 'running', 'Registering webhook…');
|
|
556
|
+
break;
|
|
557
|
+
case 'inbound':
|
|
558
|
+
step('inbound', 'done', `${e.action[0].toUpperCase() + e.action.slice(1)}: ${e.hostname} → ${e.url}`);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function banner(kind, title, text) {
|
|
564
|
+
const b = $('resultBanner');
|
|
565
|
+
b.className = `banner ${kind}`;
|
|
566
|
+
b.replaceChildren(...[h('strong', {}, title), text ? h('p', {}, text) : null].filter(Boolean));
|
|
567
|
+
b.hidden = false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function onResult(r) {
|
|
571
|
+
switch (r.status) {
|
|
572
|
+
case 'complete':
|
|
573
|
+
banner('ok', `${r.domain.domain} is authenticated with ${providerLabel()}.`, r.inbound ? 'Inbound email is routed to your webhook.' : 'You can send from this domain now.');
|
|
574
|
+
break;
|
|
575
|
+
case 'dns-pending':
|
|
576
|
+
step('propagation', 'fail', `Not visible yet: ${r.pending.map((p) => p.name).join(', ')}`);
|
|
577
|
+
banner('warn', 'DNS is still propagating.', 'This can take a while at some hosts. Run again later; finished steps are kept.');
|
|
578
|
+
break;
|
|
579
|
+
case 'validation-failed': {
|
|
580
|
+
const bad = r.validation.results.filter((x) => !x.valid).map((x) => x.reason ? `${x.record}: ${x.reason}` : x.record);
|
|
581
|
+
step('validation', 'fail', bad.join(' · '));
|
|
582
|
+
banner('warn', `DNS is live, but ${providerLabel()} hasn't verified it yet.`, `${providerLabel()} can lag a few minutes behind DNS. Run again shortly.`);
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function apply() {
|
|
589
|
+
const req = buildRequest();
|
|
590
|
+
resetSteps(Boolean(req.inbound));
|
|
591
|
+
step('domain', 'running', `Contacting ${providerLabel()}…`);
|
|
592
|
+
$('resultBanner').hidden = true;
|
|
593
|
+
$('retryBtn').hidden = true;
|
|
594
|
+
$('cancelBtn').hidden = false;
|
|
595
|
+
$('applyBtn').disabled = true;
|
|
596
|
+
$('progressCard').hidden = false;
|
|
597
|
+
$('progressCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
598
|
+
|
|
599
|
+
running = new AbortController();
|
|
600
|
+
try {
|
|
601
|
+
const res = await fetch('/api/setup', {
|
|
602
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req), signal: running.signal,
|
|
603
|
+
});
|
|
604
|
+
if (!res.ok) throw await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
605
|
+
const reader = res.body.getReader();
|
|
606
|
+
const decoder = new TextDecoder();
|
|
607
|
+
let buffer = '';
|
|
608
|
+
for (;;) {
|
|
609
|
+
const { value, done } = await reader.read();
|
|
610
|
+
if (done) break;
|
|
611
|
+
buffer += decoder.decode(value, { stream: true });
|
|
612
|
+
let nl;
|
|
613
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
614
|
+
const line = buffer.slice(0, nl);
|
|
615
|
+
buffer = buffer.slice(nl + 1);
|
|
616
|
+
if (!line) continue;
|
|
617
|
+
const msg = JSON.parse(line);
|
|
618
|
+
if (msg.type === 'event') onEvent(msg.event);
|
|
619
|
+
else if (msg.type === 'result') onResult(msg.result);
|
|
620
|
+
else if (msg.type === 'error') throw msg;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} catch (err) {
|
|
624
|
+
const current = STEP_DEFS.map(([id]) => id).find((id) => stateOf(id) === 'running');
|
|
625
|
+
if (err.name === 'AbortError') {
|
|
626
|
+
if (current) step(current, 'fail', 'Cancelled');
|
|
627
|
+
banner('warn', 'Cancelled.', 'Records already written stay in place. Run again to continue.');
|
|
628
|
+
} else {
|
|
629
|
+
if (current) step(current, 'fail');
|
|
630
|
+
banner('bad', err.error || err.message || 'Something went wrong.', err.hint);
|
|
631
|
+
}
|
|
632
|
+
} finally {
|
|
633
|
+
running = null;
|
|
634
|
+
$('cancelBtn').hidden = true;
|
|
635
|
+
$('retryBtn').hidden = false;
|
|
636
|
+
$('applyBtn').disabled = Boolean(lastPlan?.conflicts);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---- status ----
|
|
641
|
+
async function status() {
|
|
642
|
+
$('formError').hidden = true;
|
|
643
|
+
await busy($('statusBtn'), 'Checking…', async () => {
|
|
644
|
+
try {
|
|
645
|
+
const s = await api('/api/status', buildRequest());
|
|
646
|
+
const body = $('statusBody');
|
|
647
|
+
if (!s.domain) {
|
|
648
|
+
body.replaceChildren(h('p', { class: 'note' }, `No ${providerLabel()} domain exists for ${$('domain').value.trim()} yet. Preview DNS changes to create one.`));
|
|
649
|
+
} else {
|
|
650
|
+
body.replaceChildren(
|
|
651
|
+
h('div', { class: 'domainline' }, h('b', {}, s.domain.domain), h('span', {}, `#${s.domain.id} · ${s.validation.valid ? 'verified' : 'not verified'}`)),
|
|
652
|
+
...s.validation.results.map((r) => h('div', { class: 'status-row' },
|
|
653
|
+
h('span', { class: r.valid ? 'ok' : 'bad' }, r.valid ? '✓' : '✗'),
|
|
654
|
+
h('code', {}, r.record),
|
|
655
|
+
r.reason ? h('span', { class: 'note' }, r.reason) : null)),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
$('statusCard').hidden = false;
|
|
659
|
+
$('statusCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
660
|
+
} catch (err) {
|
|
661
|
+
showError($('formError'), err);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
$('form').addEventListener('submit', (e) => { e.preventDefault(); preview(); });
|
|
667
|
+
$('statusBtn').addEventListener('click', status);
|
|
668
|
+
$('applyBtn').addEventListener('click', apply);
|
|
669
|
+
$('retryBtn').addEventListener('click', apply);
|
|
670
|
+
$('replanBtn').addEventListener('click', preview);
|
|
671
|
+
$('cancelBtn').addEventListener('click', () => running?.abort());
|
|
672
|
+
$('overwrite').addEventListener('change', () => { $('applyBtn').disabled = true; });
|
|
673
|
+
|
|
674
|
+
// ---- init ----
|
|
675
|
+
restore();
|
|
676
|
+
$('inboundFields').hidden = !$('inboundOn').checked;
|
|
677
|
+
$('domain').dispatchEvent(new Event('input'));
|
|
678
|
+
api('/api/config').then((cfg) => {
|
|
679
|
+
if (cfg.version) $('version').textContent = `local · v${cfg.version}`;
|
|
680
|
+
$('sgEnv').hidden = !cfg.env.sendgrid;
|
|
681
|
+
$('cfEnv').hidden = !cfg.env.cloudflare;
|
|
682
|
+
$('awsEnvNote').hidden = !cfg.env.aws;
|
|
683
|
+
if (cfg.env.sendgrid) $('sendgridApiKey').placeholder = 'Using SENDGRID_API_KEY';
|
|
684
|
+
if (cfg.env.cloudflare) $('cloudflareApiToken').placeholder = 'Using CLOUDFLARE_API_TOKEN';
|
|
685
|
+
$('pmEnv').hidden = !cfg.env.postmark;
|
|
686
|
+
$('mgEnv').hidden = !cfg.env.mailgun;
|
|
687
|
+
if (cfg.env.postmark) $('postmarkAccountToken').placeholder = 'Using POSTMARK_ACCOUNT_TOKEN';
|
|
688
|
+
if (cfg.env.mailgun) $('mailgunApiKey').placeholder = 'Using MAILGUN_API_KEY';
|
|
689
|
+
if (cfg.env.awsRegion) { $('sesRegion').placeholder = cfg.env.awsRegion; $('sesRegionHint').textContent = 'Blank uses AWS_REGION'; }
|
|
690
|
+
let hasSaved = false;
|
|
691
|
+
try { hasSaved = Boolean(localStorage.getItem(STORE_KEY) || localStorage.getItem(LEGACY_STORE_KEY)); } catch {}
|
|
692
|
+
if (!hasSaved && !cfg.env.cloudflare && cfg.env.aws) dns = 'route53';
|
|
693
|
+
}).catch(() => {}).finally(() => { setDns(dns); setEmail(email); });
|
|
694
|
+
})();
|
|
695
|
+
</script>
|
|
696
|
+
</body>
|
|
697
|
+
</html>
|