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,669 @@
|
|
|
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>Dashboard · __APP_NAME__</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f6; --border: #e2e5ea; --text: #15181d; --muted: #5d6573;
|
|
10
|
+
--accent: #3b5bdb; --accent-hover: #3249b8; --accent-soft: #e8edff; --on-accent: #ffffff;
|
|
11
|
+
--ok: #1f8a4c; --ok-soft: #e4f5ea; --warn: #9a5800; --warn-soft: #fcf0dc; --bad: #c4302b; --bad-soft: #fdeaea;
|
|
12
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --radius: 10px;
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root:not([data-theme="light"]) {
|
|
17
|
+
--bg: #0f1115; --surface: #171a20; --surface-2: #1e222a; --border: #2a2f39; --text: #e8eaee; --muted: #9aa3b2;
|
|
18
|
+
--accent: #7b93ff; --accent-hover: #93a7ff; --accent-soft: #1f2645; --on-accent: #0b0f1f;
|
|
19
|
+
--ok: #4cc27e; --ok-soft: #14301f; --warn: #f0a640; --warn-soft: #33260f; --bad: #ff6b65; --bad-soft: #3a1717;
|
|
20
|
+
color-scheme: dark;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
:root[data-theme="dark"] {
|
|
24
|
+
--bg: #0f1115; --surface: #171a20; --surface-2: #1e222a; --border: #2a2f39; --text: #e8eaee; --muted: #9aa3b2;
|
|
25
|
+
--accent: #7b93ff; --accent-hover: #93a7ff; --accent-soft: #1f2645; --on-accent: #0b0f1f;
|
|
26
|
+
--ok: #4cc27e; --ok-soft: #14301f; --warn: #f0a640; --warn-soft: #33260f; --bad: #ff6b65; --bad-soft: #3a1717;
|
|
27
|
+
color-scheme: dark;
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; }
|
|
30
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
31
|
+
header { background: var(--surface); border-bottom: 1px solid var(--border); }
|
|
32
|
+
.bar { max-width: 960px; margin: 0 auto; padding: 12px 16px; display: flex; align-items: center; gap: 10px; }
|
|
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; }
|
|
38
|
+
.bar h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
|
39
|
+
.bar .who { margin-left: auto; display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--muted); min-width: 0; }
|
|
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); }
|
|
43
|
+
main { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; display: grid; gap: 16px; }
|
|
44
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
|
|
45
|
+
.card > h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 14px; font-weight: 600; }
|
|
46
|
+
.grid { display: grid; gap: 14px; }
|
|
47
|
+
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
48
|
+
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
|
|
49
|
+
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
50
|
+
.muted { color: var(--muted); }
|
|
51
|
+
.small { font-size: 13px; }
|
|
52
|
+
label.field { display: grid; gap: 6px; font-size: 13px; font-weight: 500; }
|
|
53
|
+
label.field .hint { font-weight: 400; color: var(--muted); }
|
|
54
|
+
input, textarea { width: 100%; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; outline: none; }
|
|
55
|
+
textarea { font-family: var(--mono); font-size: 13px; min-height: 70px; resize: vertical; }
|
|
56
|
+
input:focus-visible, textarea:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
57
|
+
.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); }
|
|
58
|
+
.btn:hover { background: var(--surface-2); }
|
|
59
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
60
|
+
.btn.primary:hover { background: var(--accent-hover); }
|
|
61
|
+
.btn:disabled { opacity: .55; cursor: default; }
|
|
62
|
+
.link { background: none; border: 0; padding: 0; color: var(--accent); font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
63
|
+
.banner { border-radius: 8px; padding: 12px 14px; font-size: 14px; }
|
|
64
|
+
.banner.ok { background: var(--ok-soft); color: var(--ok); }
|
|
65
|
+
.banner.warn { background: var(--warn-soft); color: var(--warn); }
|
|
66
|
+
.banner.bad { background: var(--bad-soft); color: var(--bad); }
|
|
67
|
+
.badge { font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 99px; background: var(--surface-2); color: var(--muted); }
|
|
68
|
+
.badge.paid { background: var(--accent-soft); color: var(--accent); }
|
|
69
|
+
.badge.bad { background: var(--bad-soft); color: var(--bad); }
|
|
70
|
+
.meter { height: 8px; border-radius: 99px; background: var(--surface-2); overflow: hidden; }
|
|
71
|
+
.meter > div { height: 100%; background: var(--accent); }
|
|
72
|
+
.meter.full > div { background: var(--warn); }
|
|
73
|
+
.big { font-size: 34px; font-weight: 650; line-height: 1.1; letter-spacing: -.01em; }
|
|
74
|
+
.stat { display: grid; gap: 2px; }
|
|
75
|
+
.stat b { font-size: 20px; font-weight: 600; }
|
|
76
|
+
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
|
77
|
+
.plan { border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; display: grid; gap: 10px; align-content: start; }
|
|
78
|
+
.plan.current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
79
|
+
.plan h3 { margin: 0; font-size: 16px; display: flex; gap: 8px; align-items: center; }
|
|
80
|
+
.plan .price { font-size: 26px; font-weight: 650; }
|
|
81
|
+
.plan .price small { font-size: 13px; font-weight: 400; color: var(--muted); }
|
|
82
|
+
.plan ul { margin: 0; padding-left: 18px; display: grid; gap: 4px; font-size: 14px; }
|
|
83
|
+
code, .code { font-family: var(--mono); font-size: 13px; }
|
|
84
|
+
pre { margin: 0; font-family: var(--mono); font-size: 12.5px; background: var(--surface-2); border-radius: 8px; padding: 12px; overflow-x: auto; }
|
|
85
|
+
.secret { display: flex; gap: 8px; align-items: center; background: var(--surface-2); border-radius: 8px; padding: 8px 10px; }
|
|
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; } }
|
|
107
|
+
.signin { max-width: 420px; margin: 8vh auto 0; }
|
|
108
|
+
.signin h2 { font-size: 24px; margin: 0 0 4px; text-transform: none; letter-spacing: 0; color: var(--text); }
|
|
109
|
+
[hidden] { display: none !important; }
|
|
110
|
+
</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<header>
|
|
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>
|
|
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>
|
|
120
|
+
<a href="/" style="display:flex;align-items:center;gap:10px;color:inherit;text-decoration:none"><h1>__APP_NAME__</h1></a>
|
|
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>
|
|
122
|
+
</div>
|
|
123
|
+
</header>
|
|
124
|
+
<main id="app" aria-live="polite" data-free-domains="__FREE_DOMAINS__"></main>
|
|
125
|
+
|
|
126
|
+
<script nonce="__NONCE__">
|
|
127
|
+
(() => {
|
|
128
|
+
const app = document.getElementById('app');
|
|
129
|
+
const params = new URLSearchParams(location.search);
|
|
130
|
+
let me = null;
|
|
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
|
+
|
|
150
|
+
function h(tag, attrs, ...children) {
|
|
151
|
+
const el = document.createElement(tag);
|
|
152
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
153
|
+
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
|
154
|
+
else if (k === 'class') el.className = v;
|
|
155
|
+
else if (v === true) el.setAttribute(k, '');
|
|
156
|
+
else if (v !== false && v != null) el.setAttribute(k, v);
|
|
157
|
+
}
|
|
158
|
+
for (const c of children.flat()) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
|
159
|
+
return el;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function api(method, path, body) {
|
|
163
|
+
const res = await fetch(path, {
|
|
164
|
+
method,
|
|
165
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
166
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
167
|
+
});
|
|
168
|
+
const data = await res.json().catch(() => ({}));
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const err = new Error(data.error?.message || 'Something went wrong. Please try again.');
|
|
171
|
+
err.status = res.status;
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
return data;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const money = (cents, currency) =>
|
|
178
|
+
new Intl.NumberFormat(undefined, { style: 'currency', currency: (currency || 'usd').toUpperCase() }).format(cents / 100);
|
|
179
|
+
const dayLabel = (d) => d ? new Date(d + 'T00:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) : '';
|
|
180
|
+
|
|
181
|
+
async function busy(button, label, fn) {
|
|
182
|
+
const original = button.textContent;
|
|
183
|
+
button.disabled = true;
|
|
184
|
+
button.textContent = label;
|
|
185
|
+
try { return await fn(); } finally { button.disabled = false; button.textContent = original; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function copyButton(text) {
|
|
189
|
+
return h('button', { class: 'btn', type: 'button', onclick: async (e) => {
|
|
190
|
+
try { await navigator.clipboard.writeText(text); e.target.textContent = 'Copied'; } catch { e.target.textContent = 'Select & copy'; }
|
|
191
|
+
setTimeout(() => (e.target.textContent = 'Copy'), 1400);
|
|
192
|
+
} }, 'Copy');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- signed out ----
|
|
196
|
+
function renderSignIn(sent) {
|
|
197
|
+
document.getElementById('who').hidden = true;
|
|
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)' });
|
|
200
|
+
const error = h('div', { class: 'banner bad', role: 'alert', hidden: true });
|
|
201
|
+
const submit = h('button', { class: 'btn primary', type: 'submit' }, 'Continue with email');
|
|
202
|
+
const form = h('form', { class: 'grid', onsubmit: async (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
error.hidden = true;
|
|
205
|
+
await busy(submit, 'Sending…', async () => {
|
|
206
|
+
try {
|
|
207
|
+
await api('POST', '/v1/auth/email-link', { email: email.value.trim(), name: name.value.trim() || undefined });
|
|
208
|
+
renderSignIn(email.value.trim());
|
|
209
|
+
} catch (err) {
|
|
210
|
+
error.textContent = err.message;
|
|
211
|
+
error.hidden = false;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
} },
|
|
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),
|
|
217
|
+
error, submit,
|
|
218
|
+
h('p', { class: 'small muted' }, 'No password needed. We email you a sign-in link. New here? The same link creates your account.'));
|
|
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.';
|
|
225
|
+
app.replaceChildren(h('div', { class: 'signin card grid' },
|
|
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,
|
|
227
|
+
sent
|
|
228
|
+
? h('div', { class: 'grid' },
|
|
229
|
+
h('h2', {}, 'Check your inbox'),
|
|
230
|
+
h('p', { class: 'muted' }, `We sent a sign-in link to ${sent}. It works once and expires in 15 minutes.`),
|
|
231
|
+
h('button', { class: 'link', type: 'button', onclick: () => renderSignIn() }, 'Use a different email'))
|
|
232
|
+
: h('div', { class: 'grid' },
|
|
233
|
+
h('div', {}, h('h2', {}, 'Start free'), h('p', { class: 'muted', style: 'margin:0' }, intro)),
|
|
234
|
+
form)));
|
|
235
|
+
if (!sent) email.focus();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- signed in ----
|
|
239
|
+
function planCard() {
|
|
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
|
+
}
|
|
253
|
+
if (!me.billing_enabled) {
|
|
254
|
+
return h('section', { class: 'card grid' }, h('h2', {}, 'Plan'), h('p', { class: 'muted' }, 'Billing is not enabled on this server.'));
|
|
255
|
+
}
|
|
256
|
+
const paid = b.plan === 'pay_as_you_go';
|
|
257
|
+
const badge = b.status === 'past_due' ? h('span', { class: 'badge bad' }, 'Payment issue') : h('span', { class: paid ? 'badge paid' : 'badge' }, paid ? 'Pay as you go' : 'Free');
|
|
258
|
+
|
|
259
|
+
const checkout = h('button', { class: 'btn primary', type: 'button', onclick: (e) => go(e.target, '/dashboard/api/checkout') }, 'Add payment method');
|
|
260
|
+
const portal = h('button', { class: paid && b.status !== 'past_due' ? 'btn' : 'btn primary', type: 'button', onclick: (e) => go(e.target, '/dashboard/api/portal') }, b.status === 'past_due' ? 'Update payment method' : 'Manage billing');
|
|
261
|
+
|
|
262
|
+
let body;
|
|
263
|
+
if (paid) {
|
|
264
|
+
body = h('div', { class: 'grid' },
|
|
265
|
+
h('div', { class: 'small muted' }, `This billing period · ${dayLabel(b.period_start)}${b.period_end ? ' – ' + dayLabel(b.period_end) : ''}`),
|
|
266
|
+
h('div', { class: 'row', style: 'align-items:baseline;gap:16px' },
|
|
267
|
+
h('div', {}, h('div', { class: 'big' }, money(b.amount_due, b.currency)), h('div', { class: 'small muted' }, 'estimated so far')),
|
|
268
|
+
h('div', { class: 'small muted' },
|
|
269
|
+
`${b.active_domains} active domain${b.active_domains === 1 ? '' : 's'}: first ${b.free_domains} free`,
|
|
270
|
+
b.billable_domains ? `, then ${b.billable_domains} × ${money(b.unit_amount, b.currency)}` : '')),
|
|
271
|
+
b.status === 'past_due' ? h('div', { class: 'banner bad' }, 'Your last payment failed. Update your payment method to keep your domains running smoothly.') : null,
|
|
272
|
+
h('div', { class: 'row' }, portal));
|
|
273
|
+
} else {
|
|
274
|
+
const used = me.usage.current_domains;
|
|
275
|
+
const pct = Math.min(100, Math.round((used / b.free_domains) * 100));
|
|
276
|
+
body = h('div', { class: 'grid' },
|
|
277
|
+
h('div', { class: 'row', style: 'justify-content:space-between' },
|
|
278
|
+
h('b', {}, `${used} of ${b.free_domains} free domains used`),
|
|
279
|
+
used >= b.free_domains ? h('span', { class: 'small', style: 'color:var(--warn)' }, 'Limit reached') : null),
|
|
280
|
+
h('div', { class: used >= b.free_domains ? 'meter full' : 'meter', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': b.free_domains, 'aria-valuenow': used }, h('div', { style: `width:${pct}%` })),
|
|
281
|
+
h('p', { class: 'small muted', style: 'margin:0' },
|
|
282
|
+
`Need more? Add a payment method: your first ${b.free_domains} active domains stay free, then ${money(b.unit_amount, b.currency)} per active domain per month. Cancel anytime.`),
|
|
283
|
+
h('div', { class: 'row' }, checkout));
|
|
284
|
+
}
|
|
285
|
+
return h('section', { class: 'card grid' }, h('div', { class: 'row', style: 'justify-content:space-between' }, h('h2', { style: 'margin:0' }, 'Plan'), badge), body);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function pricingCard() {
|
|
289
|
+
if (!me.billing_enabled) return null;
|
|
290
|
+
const b = me.billing;
|
|
291
|
+
const paid = b.plan === 'pay_as_you_go';
|
|
292
|
+
const plan = (title, price, unit, items, current) => h('div', { class: current ? 'plan current' : 'plan' },
|
|
293
|
+
h('h3', {}, title, current ? h('span', { class: 'badge paid' }, 'Current') : null),
|
|
294
|
+
h('div', { class: 'price' }, price, h('small', {}, unit)),
|
|
295
|
+
h('ul', {}, items.map((i) => h('li', {}, i))));
|
|
296
|
+
return h('section', { class: 'card grid' }, h('h2', {}, 'Pricing'),
|
|
297
|
+
h('div', { class: 'cols' },
|
|
298
|
+
plan('Free', money(0, b.currency), ' forever', [`Up to ${b.free_domains} domains`, 'CLI, API and embeddable widget', 'Cloudflare, Route 53 and manual DNS', 'Webhooks and usage reports'], !paid),
|
|
299
|
+
plan('Pay as you go', money(b.unit_amount, b.currency), ' / active domain / month', [`First ${b.free_domains} active domains free`, 'Unlimited domains', 'Billed monthly for what you use', 'Cancel anytime from the billing portal'], paid)));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function apiKeyCard() {
|
|
303
|
+
const out = h('div', { class: 'grid' });
|
|
304
|
+
const create = h('button', { class: 'btn', type: 'button', onclick: async () => {
|
|
305
|
+
if (!confirm('Create a new API key? Your current key stops working immediately.')) return;
|
|
306
|
+
await busy(create, 'Creating…', async () => {
|
|
307
|
+
try {
|
|
308
|
+
const { api_key } = await api('POST', '/dashboard/api/api-key');
|
|
309
|
+
out.replaceChildren(
|
|
310
|
+
h('div', { class: 'banner warn' }, 'Copy this key now. It won\'t be shown again.'),
|
|
311
|
+
h('div', { class: 'secret' }, h('code', {}, api_key), copyButton(api_key)));
|
|
312
|
+
} catch (err) { out.replaceChildren(h('div', { class: 'banner bad' }, err.message)); }
|
|
313
|
+
});
|
|
314
|
+
} }, 'Create new API key');
|
|
315
|
+
return h('section', { class: 'card grid' }, h('h2', {}, 'API key'),
|
|
316
|
+
h('p', { class: 'small muted', style: 'margin:0' }, 'Use it as Authorization: Bearer <key> with the API. For security we only store a hash, so a key is shown once when created.'),
|
|
317
|
+
h('div', { class: 'row' }, create), out);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Credential fields per email provider: [API field, label, hint, secret?]
|
|
321
|
+
const EMAIL_FIELDS = {
|
|
322
|
+
sendgrid: [['api_key', 'SendGrid API key', 'Sender Authentication and Inbound Parse access', true]],
|
|
323
|
+
postmark: [['account_token', 'Account API token', 'Account → API Tokens (not a server token)', true], ['inbound_server_id', 'Inbound server ID', 'only needed to receive email', false]],
|
|
324
|
+
mailgun: [['api_key', 'Mailgun API key', 'a private API key', true], ['region', 'Region', 'us or eu', false]],
|
|
325
|
+
ses: [['access_key_id', 'AWS access key ID', 'IAM user with SES access', false], ['secret_access_key', 'AWS secret access key', '', true], ['region', 'AWS region', 'e.g. us-east-1', false]],
|
|
326
|
+
};
|
|
327
|
+
const EMAIL_LABELS = { sendgrid: 'SendGrid', postmark: 'Postmark', mailgun: 'Mailgun', ses: 'Amazon SES' };
|
|
328
|
+
|
|
329
|
+
function settingsCard() {
|
|
330
|
+
const a = me.account;
|
|
331
|
+
const provider = h('select', { 'aria-label': 'Email provider', style: 'font:inherit;padding:9px 11px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text)' },
|
|
332
|
+
Object.entries(EMAIL_LABELS).map(([value, label]) => h('option', { value, selected: value === a.email_provider }, label)));
|
|
333
|
+
const fieldsBox = h('div', { class: 'grid' });
|
|
334
|
+
let inputs = {};
|
|
335
|
+
const renderFields = () => {
|
|
336
|
+
const p = provider.value;
|
|
337
|
+
const saved = p === a.email_provider && a.email_credentials_set;
|
|
338
|
+
inputs = {};
|
|
339
|
+
fieldsBox.replaceChildren(...EMAIL_FIELDS[p].map(([key, label, hint, secret]) => {
|
|
340
|
+
const input = h('input', { type: secret ? 'password' : 'text', autocomplete: 'off', spellcheck: 'false', placeholder: saved ? 'Saved. Leave blank to keep it' : '' });
|
|
341
|
+
inputs[key] = input;
|
|
342
|
+
return h('label', { class: 'field' }, h('span', {}, `${label} `, hint ? h('span', { class: 'hint' }, `(${hint})`) : null), input);
|
|
343
|
+
}), p === 'ses' ? h('p', { class: 'small muted', style: 'margin:0' }, 'Receiving email isn’t supported with Amazon SES yet.') : null,
|
|
344
|
+
p === 'mailgun' ? h('p', { class: 'small muted', style: 'margin:0' }, 'Mailgun recommends sending from a subdomain, like mg.yourcustomer.com.') : null);
|
|
345
|
+
};
|
|
346
|
+
provider.addEventListener('change', renderFields);
|
|
347
|
+
renderFields();
|
|
348
|
+
|
|
349
|
+
const hook = h('input', { type: 'url', value: a.webhook_url || '', placeholder: 'https://api.yourapp.com/webhooks/email-domains' });
|
|
350
|
+
const origins = h('textarea', { placeholder: 'https://app.yourapp.com' }, (a.widget_origins || []).join('\n'));
|
|
351
|
+
const status = h('div', { hidden: true });
|
|
352
|
+
const save = h('button', { class: 'btn primary', type: 'submit' }, 'Save settings');
|
|
353
|
+
const secret = h('code', {}, '•'.repeat(24));
|
|
354
|
+
return h('section', { class: 'card' }, h('h2', {}, 'Settings'),
|
|
355
|
+
h('form', { class: 'grid', onsubmit: async (e) => {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
const credentials = Object.fromEntries(Object.entries(inputs).map(([k, el]) => [k, el.value.trim()]).filter(([, v]) => v));
|
|
358
|
+
// Send provider settings only when something changed; blank fields keep saved values.
|
|
359
|
+
const providerChanged = provider.value !== a.email_provider;
|
|
360
|
+
const patch = {
|
|
361
|
+
webhook_url: hook.value.trim() || null,
|
|
362
|
+
widget_origins: origins.value.split(/\s+/).map((o) => o.trim()).filter(Boolean),
|
|
363
|
+
...(providerChanged || Object.keys(credentials).length ? { email_provider: { provider: provider.value, ...credentials } } : {}),
|
|
364
|
+
};
|
|
365
|
+
await busy(save, 'Saving…', async () => {
|
|
366
|
+
try {
|
|
367
|
+
me = await api('PATCH', '/dashboard/api/account', patch);
|
|
368
|
+
render({ saved: true });
|
|
369
|
+
} catch (err) { status.className = 'banner bad'; status.textContent = err.message; status.hidden = false; }
|
|
370
|
+
});
|
|
371
|
+
} },
|
|
372
|
+
h('label', { class: 'field' }, h('span', {}, 'Email provider ', h('span', { class: 'hint' }, '(where your customers’ domains are authenticated)')), provider),
|
|
373
|
+
fieldsBox,
|
|
374
|
+
h('label', { class: 'field' }, h('span', {}, 'Webhook URL ', h('span', { class: 'hint' }, '(domain status events)')), hook),
|
|
375
|
+
h('div', { class: 'grid', style: 'gap:6px' }, h('span', { class: 'small', style: 'font-weight:500' }, 'Webhook signing secret'),
|
|
376
|
+
h('div', { class: 'secret' }, secret,
|
|
377
|
+
h('button', { class: 'btn', type: 'button', onclick: (e) => { secret.textContent = a.webhook_secret; e.target.remove(); } }, 'Reveal'),
|
|
378
|
+
copyButton(a.webhook_secret))),
|
|
379
|
+
h('label', { class: 'field' }, h('span', {}, 'Widget origins ', h('span', { class: 'hint' }, '(one per line; sites allowed to embed the setup widget)')), origins),
|
|
380
|
+
status, h('div', { class: 'row' }, save)));
|
|
381
|
+
}
|
|
382
|
+
|
|
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
|
+
|
|
593
|
+
function usageCard() {
|
|
594
|
+
const u = me.usage;
|
|
595
|
+
const stat = (n, label) => h('div', { class: 'stat' }, h('b', {}, String(n)), h('span', { class: 'small muted' }, label));
|
|
596
|
+
return h('section', { class: 'card grid' }, h('h2', {}, `Usage this month · ${dayLabel(u.from)} – ${dayLabel(u.to)}`),
|
|
597
|
+
h('div', { class: 'stats' }, stat(u.active_domains, 'active domains'), stat(u.domains_created, 'domains created'), stat(u.verifications, 'verifications')));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function quickstartCard() {
|
|
601
|
+
const base = location.origin;
|
|
602
|
+
const curl = `curl -X POST ${base}/v1/domains \\\n -H "Authorization: Bearer $API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"domain": "yourcustomer.com", "dns": {"provider": "manual"}}'`;
|
|
603
|
+
return h('section', { class: 'card grid' }, h('h2', {}, 'Quickstart'),
|
|
604
|
+
h('p', { class: 'small muted', style: 'margin:0' }, 'Connect your email provider in Settings, create an API key, then register a domain:'),
|
|
605
|
+
h('pre', {}, curl), h('div', {}, copyButton(curl)));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function go(button, path) {
|
|
609
|
+
return busy(button, 'Opening…', async () => {
|
|
610
|
+
try {
|
|
611
|
+
const { url } = await api('POST', path);
|
|
612
|
+
location.assign(url);
|
|
613
|
+
} catch (err) { alert(err.message); }
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function render(flags = {}) {
|
|
618
|
+
document.getElementById('who').hidden = false;
|
|
619
|
+
document.getElementById('whoEmail').textContent = me.email || me.account.name;
|
|
620
|
+
document.getElementById('feedback').hidden = !me.feedback_enabled;
|
|
621
|
+
const banners = [];
|
|
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.'));
|
|
623
|
+
if (params.get('billing') === 'return') {
|
|
624
|
+
// The provider returns here after success or failure; the webhook decides.
|
|
625
|
+
banners.push(me.billing?.plan === 'pay_as_you_go'
|
|
626
|
+
? h('div', { class: 'banner ok' }, 'Payment method added. You\'re on pay as you go.')
|
|
627
|
+
: flags.checkoutPending
|
|
628
|
+
? h('div', { class: 'banner ok' }, 'Checking your payment…')
|
|
629
|
+
: h('div', { class: 'banner warn' }, 'We haven\'t received a payment confirmation yet. If you completed checkout, refresh this page in a minute.'));
|
|
630
|
+
}
|
|
631
|
+
if (params.get('billing') === 'canceled') banners.push(h('div', { class: 'banner warn' }, 'Checkout canceled. Nothing was charged.'));
|
|
632
|
+
if (flags.saved) banners.push(h('div', { class: 'banner ok' }, 'Settings saved.'));
|
|
633
|
+
app.replaceChildren(...banners, domainsCard(), planCard(), ...[pricingCard(), usageCard(), apiKeyCard(), settingsCard(), quickstartCard()].filter(Boolean));
|
|
634
|
+
renderDomains();
|
|
635
|
+
if (domains === null) loadDomains();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function load() {
|
|
639
|
+
try {
|
|
640
|
+
me = await api('GET', '/dashboard/api/me');
|
|
641
|
+
// After checkout, the provider's webhook may land a moment after the redirect.
|
|
642
|
+
const awaitingPayment = params.get('billing') === 'return' && me.billing?.plan !== 'pay_as_you_go';
|
|
643
|
+
render({ checkoutPending: awaitingPayment });
|
|
644
|
+
if (awaitingPayment) {
|
|
645
|
+
for (let i = 0; i < 10 && me.billing?.plan !== 'pay_as_you_go'; i++) {
|
|
646
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
647
|
+
me = await api('GET', '/dashboard/api/me');
|
|
648
|
+
}
|
|
649
|
+
render();
|
|
650
|
+
}
|
|
651
|
+
} catch (err) {
|
|
652
|
+
if (err.status === 401) renderSignIn();
|
|
653
|
+
else app.replaceChildren(h('div', { class: 'banner bad' }, err.message));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
document.getElementById('signOut').addEventListener('click', async () => {
|
|
658
|
+
await api('POST', '/dashboard/api/logout').catch(() => {});
|
|
659
|
+
me = null;
|
|
660
|
+
domains = null;
|
|
661
|
+
clearTimeout(refreshTimer);
|
|
662
|
+
history.replaceState(null, '', '/dashboard');
|
|
663
|
+
renderSignIn();
|
|
664
|
+
});
|
|
665
|
+
load();
|
|
666
|
+
})();
|
|
667
|
+
</script>
|
|
668
|
+
</body>
|
|
669
|
+
</html>
|