@kurtbruns/kestrel 1.12.0 → 1.13.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/CHANGELOG.md +103 -1
- package/cli/dev-api.mjs +1 -1
- package/cli/seed.mjs +1 -1
- package/dist/public/dashboard/app.js +341 -291
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +395 -116
- package/dist/worker/index.js +8722 -5392
- package/docs/get-started/02-deploy.md +1 -1
- package/docs/get-started/03-access.md +3 -3
- package/docs/get-started/04-ses.md +2 -2
- package/docs/get-started/05-verify.md +4 -4
- package/docs/get-started/06-go-live.md +1 -1
- package/docs/guides/01-connect-claude.md +7 -4
- package/docs/guides/09-as-your-list-grows.md +6 -2
- package/docs/publishing/01-your-list.md +35 -30
- package/docs/publishing/02-sending-rules.md +3 -3
- package/docs/publishing/04-write-a-post.md +2 -2
- package/docs/publishing/06-troubleshooting.md +10 -5
- package/docs/publishing/07-working-with-claude.md +1 -2
- package/docs/publishing/09-share-your-subscribe-link.md +2 -2
- package/docs/publishing/10-check-your-setup.md +2 -2
- package/docs/publishing/11-email-template.md +2 -0
- package/docs/reference/01-configuration.md +2 -2
- package/migrations/0012_unsubscribe_cause.sql +139 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +11 -10
- package/template/claude/skills/migrate-list/SKILL.md +5 -4
|
@@ -1,261 +1,273 @@
|
|
|
1
1
|
// GENERATED by scripts/build-client.mjs from client/ — edit the source there, never this file.
|
|
2
|
-
"use strict";(()=>{function _s(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var he=_s("app"),Ls=_s("identity"),Rs=_s("toasts"),Go=document.getElementById("navToggle"),Es=document.getElementById("navScrim");function Kt(e){document.body.classList.toggle("nav-open",e),Go?.setAttribute("aria-expanded",e?"true":"false"),Es&&(Es.hidden=!e)}var Vt=document.getElementById("navCollapse"),Ts=null;function cl(){return window.innerWidth<540?"full":Ts==="collapsed"?"rail":Ts==="expanded"?"full":window.innerWidth<=1024?"rail":"full"}function ks(){let e=cl();if(document.documentElement.dataset.nav=e,!Vt)return;let t=e!=="rail",n=t?"Collapse sidebar":"Expand sidebar";Vt.setAttribute("aria-expanded",t?"true":"false"),Vt.setAttribute("aria-label",n),Vt.setAttribute("title",n)}var xs=0,Ko=0;function Yo(){Go?.addEventListener("click",()=>Kt(!document.body.classList.contains("nav-open"))),Es?.addEventListener("click",()=>Kt(!1)),document.querySelector(".sidebar")?.addEventListener("click",e=>{e.target instanceof Element&&e.target.closest("a")&&Kt(!1)}),Vt?.addEventListener("click",()=>{Ts=document.documentElement.dataset.nav!=="rail"?"collapsed":"expanded",ks()}),window.addEventListener("resize",()=>{document.body.classList.add("nav-resizing"),clearTimeout(Ko),Ko=setTimeout(()=>{document.body.classList.remove("nav-resizing")},200),!xs&&(xs=requestAnimationFrame(()=>{xs=0,ks()}))}),ks()}var wn=null,Sn=null;function X(e){wn?.abort(),Sn=null;let t=new AbortController;wn=t;let n=document.createElement("div");n.className="view",he.replaceChildren(n);let s;try{s=Promise.resolve(e(n,t.signal))}catch(i){s=Promise.reject(i)}return s.then(i=>{!t.signal.aborted&&i&&(Sn=i)},i=>{if(!t.signal.aborted)throw i})}function kt(){wn?.abort(),wn=null,Sn=null,he.replaceChildren()}function $n(){return Sn}function ot(e,t,n){if(n.aborted)return;let s=setInterval(t,e);n.addEventListener("abort",()=>clearInterval(s),{once:!0})}function it(e,t,n){if(n.aborted)return;let s=()=>clearTimeout(i),i=setTimeout(()=>{n.removeEventListener("abort",s),t()},Math.max(0,e-Date.now()));n.addEventListener("abort",s,{once:!0})}function zo(e,t,n){let s=()=>{if(n.aborted)return;let i=()=>clearTimeout(r),r=setTimeout(async()=>{n.removeEventListener("abort",i);let a=!0;try{a=await t()??!0}catch(l){dl(l)||reportError(l)}a!==!1&&s()},e);n.addEventListener("abort",i,{once:!0})};s()}function rt(e,t){if(e.aborted){t();return}e.addEventListener("abort",t,{once:!0})}function dl(e){return e instanceof Error&&(e.name==="AbortError"||e.name==="ApiError")}function c(e,t=document){let n=t.querySelector(e);if(!n)throw new Error(`no element matches ${e}`);return n}function M(e,t=document){return[...t.querySelectorAll(e)]}var ul={"&":"&","<":"<",">":">",'"':""","'":"'"};function ae(e){return String(e).replace(/[&<>"']/g,t=>ul[t]??t)}var at=class{markup;constructor(t){this.markup=t,Object.freeze(this)}toString(){return this.markup}};function Jo(e){return e instanceof at?e.markup:e==null||e===!1?"":Array.isArray(e)?e.map(Jo).join(""):ae(e)}var pl=/=\s*$/;function o(e,...t){let n=Zo(e,0);for(let s=0;s<t.length;s++){let i=t[s];if(!(i instanceof at)&&pl.test(n))throw new Error("html: an interpolation in an unquoted attribute value cannot be made safe; quote it");n+=Jo(i)+Zo(e,s+1)}return new at(n)}function Zo(e,t){let n=e[t];if(n===void 0)throw new Error(`html: invalid escape sequence in template text near ${JSON.stringify(e.raw[t])}`);return n}function q(e){return new at(e)}function h(e,t){if(!(t instanceof at))throw new Error("setHtml: expected Html; build it with html`\u2026` or vouch for it with unsafeHtml()");e.innerHTML=t.markup}var ml="https://getkestrel.dev/docs/access/";function kn(e){if(!e||typeof e!="object")return!1;let t=e;return t.error==="unauthorized"&&t.reason==="access_not_configured"}function xn(){he.querySelector(".access-wall")||(kt(),document.body.classList.add("signed-out"),h(he,o`<div class="card auth-wall access-wall"><h2 tabindex="-1">The editor is locked until you set up Access</h2><p>Until Cloudflare Access is in front of the editor, the app can’t tell who is asking, so it lets no one in. On a new deployment, that’s expected. Your public pages work as usual.</p><p class="hint"><a href="${ml}" target="_blank" rel="noopener">Lock the dashboard with Access <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p><button id="access-reload" class="primary">Reload</button></div>`),c("#access-reload",he).onclick=()=>location.reload(),c(".access-wall h2",he).focus())}function En(e){if(!e||typeof e!="object")return null;let t=e;return t.error==="schema_behind"&&typeof t.message=="string"?{message:t.message}:t.error!=="invalid_config"||typeof t.variable!="string"?null:{variable:t.variable,message:typeof t.message=="string"?t.message:`${t.variable} is refused`}}var fl="https://getkestrel.dev/docs/configuration/",hl={PROVIDER:"provider",ALLOW_FAKE_PROVIDER:"provider",APP_ORIGIN:"vars",AWS_REGION:"vars",FROM_ADDRESS:"from_address-and-sending_domain",SENDING_DOMAIN:"from_address-and-sending_domain",ARCHIVE_BASE_PATH:"archive_base_path",SES_MAX_SEND_RATE:"ses_max_send_rate",SUBREQUEST_BUDGET:"subrequest_budget",MIN_LEAD_SECONDS:"min_lead_seconds",ARCHIVE_ORIGIN:"archive_origin-and-media_public_base",MEDIA_PUBLIC_BASE:"archive_origin-and-media_public_base",NOTIFY_FROM:"notify_from",ACCESS_TEAM_DOMAIN:"secrets",ACCESS_AUD:"secrets",ACCESS_ALLOWED_EMAILS:"secrets",AWS_ACCESS_KEY_ID:"secrets",AWS_SECRET_ACCESS_KEY:"secrets",SNS_TOPIC_ARN:"secrets",SES_CONFIGURATION_SET:"secrets",SIMULATE_SENDS:"what-the-app-refuses"},bl="what-the-app-refuses";function gl(e){return`${fl}#${hl[e]??bl}`}function Tn(e){if(he.querySelector(".config-wall"))return;kt(),document.body.classList.add("signed-out");let{variable:t}=e,n=t?"A setting needs fixing":"The database needs updating",s=t?o`<p class="hint"><a href="${gl(t)}" target="_blank" rel="noopener">${t} in the Configuration reference <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p>`:"";h(he,o`<div class="card auth-wall config-wall" role="alert"><h2 tabindex="-1">${n}</h2><p>Kestrel won’t run until it’s fixed, and nothing is sent in the meantime.</p><p class="config-wall-problem">${e.message}</p>${s}<button id="config-reload" class="primary">Reload</button></div>`),c("#config-reload",he).onclick=()=>location.reload(),c(".config-wall h2",he).focus()}var _n="kestrel_token",Q={token:localStorage.getItem(_n)||"",session:null,appConfig:null};function ei(e){Q.token=(e||"").trim();try{Q.token?localStorage.setItem(_n,Q.token):localStorage.removeItem(_n)}catch{}}function Cs(){return Q.token?{Authorization:`Bearer ${Q.token}`}:{}}function ti(){let e=Q.session;if(e?.auth.mode==="access"){let t=e.principal,n=t.email||(t.kind==="service"?"Service token":"Signed in");h(Ls,o`<span class="who" title="${n}">${n}</span><a class="ghost" href="/cdn-cgi/access/logout" title="Sign out"><svg class="signout-icon" aria-hidden="true"><use href="#i-signout"/></svg><span class="signout-label">Sign out</span></a>`)}else h(Ls,o`<span class="who dev" title="Local dev: sign-in is skipped on localhost">Local dev</span>`)}function As(){he.querySelector(".reauth-wall")||(kt(),document.body.classList.add("signed-out"),h(he,o`<div class="card auth-wall reauth-wall"><h2 tabindex="-1">Session expired</h2><p class="hint">Your access session ended. Sign in again to continue.</p><button id="reauth" class="primary">Sign in</button></div>`),c("#reauth",he).onclick=()=>location.reload(),c(".reauth-wall h2",he).focus())}async function Qo(){try{let e=await fetch("/api/dev/token?kind=human");if(e.ok)return ei((await e.json()).token),!0}catch{}return!1}async function Xo(){try{return await fetch("/api/whoami",{headers:Cs(),redirect:"manual"})}catch{return null}}async function ni(){Q.token||await Qo();let e=await Xo();if(!e?.ok&&Q.token&&(ei(""),await Qo()&&(e=await Xo())),e?.ok)return await e.json();let t=e?await e.json().catch(()=>null):null,n=En(t);return n?Tn(n):kn(t)?xn():As(),null}var J=class extends Error{constructor(n,s,i=null){super(n);this.status=s;this.data=i;this.name="ApiError"}status;data;get code(){let n=this.data;return typeof n?.error=="string"?n.error:null}get field(){let n=this.data;return typeof n?.field=="string"?n.field:null}},vl="Not authorized. Sign in again.",yl=e=>/^[a-z_]+$/i.test(e.trim()),wl=/^(Failed to fetch|NetworkError when attempting to fetch resource\.?|Load failed|The network connection was lost\.?|The Internet connection appears to be offline\.?)$/;function Hs(e){return e instanceof TypeError&&wl.test(e.message)}function W(e){if(e instanceof J){let t=e.data?.message;return typeof t=="string"&&!yl(t)?t:e.status===401?e.message:e.status===404?"It isn\u2019t there any more. It may have been deleted elsewhere.":e.status>=500?"Something went wrong on the server. Try again in a moment.":e.status===429?"Too many requests at once. Try again in a moment.":"The server couldn\u2019t do that. Reload the page and try again."}return Hs(e)?"Couldn\u2019t reach Kestrel. Check your connection and try again.":e instanceof Error?e.message:String(e)}async function Ps(e,t){let n={...Cs(),...t.headers??{}},s=t.body;t.json!==void 0&&(n["content-type"]="application/json",s=JSON.stringify(t.json));let i=await fetch(e,{method:t.method??"GET",headers:n,body:s,signal:t.signal});if(i.status===401){let r=await i.json().catch(()=>null);throw kn(r)?xn():As(),new J(vl,401,r)}return i}function Sl(e){if(e&&typeof e=="object"){let t=e;if(typeof t.message=="string")return t.message;if(typeof t.error=="string")return t.error}}async function Ms(e){let t=await e.text(),n=null;try{n=t?JSON.parse(t):null}catch{}let s=En(n);s&&Tn(s);let i=e.statusText||`Request failed (HTTP ${e.status})`;return new J(Sl(n)??i,e.status,n)}async function R(e,t={}){let n=await Ps(e,t);if(!n.ok)throw await Ms(n);let s=await n.text();return s?JSON.parse(s):null}async function xt(e,t={}){let n=await Ps(e,t);if(!n.ok)throw await Ms(n);return n.text()}async function Ln(e,t={}){let n=await Ps(e,t);if(!n.ok)throw await Ms(n);let s=n.headers.get("content-disposition")??"",i=/filename="([^"]+)"/.exec(s)?.[1]??null;return{text:await n.text(),filename:i}}function Is(e){let t=e.trim(),n=t.lastIndexOf("<");if(n<0||!t.endsWith(">"))return"";let s=t.slice(0,n).trim();return s.length>=2&&s.startsWith('"')&&s.endsWith('"')?s.slice(1,-1).replace(/\\(.)/g,"$1").trim():s}function Gt(e,t){return e||Is(t)}function Ns(e){let t=e?.settings.publication;return{name:Gt(t?.name||"",e?.deployment.fromAddress??"")||"Your publication",tagline:t?.tagline||"",logoUrl:t?.logoUrl||null}}function Rn(){let e=Ns(Q.appConfig),t=document.getElementById("brandName"),n=document.getElementById("brandTagline"),s=document.getElementById("brandLogo");t&&(t.textContent=e.name),n&&(n.textContent=e.tagline,n.hidden=!e.tagline),s&&(e.logoUrl?(h(s,o`<img src="${e.logoUrl}" alt="">`),s.classList.remove("brand-logo-placeholder")):(s.textContent=(e.name.trim()[0]||"K").toUpperCase(),s.classList.add("brand-logo-placeholder")))}function si(e,t,n){return`${e}${t}/${n}`}var xl=["content","invalid_request"];function Os(e){return e!==null&&xl.includes(e)}var Ds={step:"Connect Amazon SES",guide:" (the setup guide walks through it)",after:", then deploy."};var Ep=1800*1e3,An=300*1e3,Tp=60*1e3,_p=1440*60*1e3;function Et(e){let t=Math.round(e/1e3);if(t%60===0){let n=t/60;return`${n} minute${n===1?"":"s"}`}return`${t} seconds`}function oi(e){return`Pick a time at least ${Et(e)} from now, so there\u2019s time to cancel.`}var Lp=10080*60*1e3,Rp=3600*1e3,Cn=Math.floor(7e4/14);function ii(e,t=!1){let n=e.toLocaleString("en-US");return t?`On the Workers Free plan\u2019s limits, a send to ${n} recipients on the same day as today\u2019s imports may use up the database\u2019s daily allowance of writes. That stops this newsletter until midnight UTC, the send included. Send it tomorrow (UTC), or move to Workers Paid, which is recommended for a list of more than ${Cn.toLocaleString("en-US")}.`:`On the Workers Free plan\u2019s limits, a send to ${n} recipients may use up the database\u2019s daily allowance of writes. That stops this newsletter until midnight UTC, the send included. Workers Paid is recommended for a list of more than ${Cn.toLocaleString("en-US")}.`}function ri(){let e=Q.appConfig?.deployment.build;if(!e?.version)return null;let t=e.tagUrl?o`<a class="build-link" href="${e.tagUrl}" target="_blank" rel="noopener">v${e.version}</a>`:o`v${e.version}`,n=e.commitUrl?o`<a class="build-link" href="${e.commitUrl}" target="_blank" rel="noopener">${e.sha}</a>`:o`${e.sha}`;return{version:t,sha:n}}function Hn(){return Q.appConfig?.deployment.minLeadMs??An}function Pn(){return oi(Hn())}function ai(){let e=Hn();if(e%6e4===0){let t=e/6e4;return`${t} to ${t+1} minutes`}return`${Et(e)} to ${Et(e+6e4)}`}function Mn(e,t=Date.now()){return e<t+Hn()}function In(e=Date.now()){return new Date(e+Hn()+6e4)}function Yt(){return Q.appConfig?.deployment.provider==="fake"}function fe(){return Q.appConfig?.deployment.provider==="none"}function El(){return Q.appConfig?.deployment.freePlanLimits===!0}function Tl(){return fe()&&Q.appConfig?.deployment.notConnectedPreview===!0}var Bs="#/docs/ses";function Ce(e){let t=e?o` class="${e}"`:null;return o`<a${t} href="${Bs}">${Ds.step}</a>`}function Fs(){return Tl()?o`Restart <code>npm run dev</code> without <code>SIMULATE_SENDS=not-connected</code> to send.`:o`${Ce()}${Ds.after}`}function Nn(e,t){let n=t===1;return o`No email provider connected, so nothing can be sent and ${e} ${n?"is":"are"} held, with no one marked unsent; ${n?"it goes out at its":"they go out at their"} first retry once one is connected. ${Fs()}`}function On(e,t){return fe()?o`<div class="health amber ${e}" role="status"><span class="health-dot">⚠️</span><div><span>${t}</span></div></div>`:null}function Dn(e){if(!fe())return null;let t="Will be held: no email provider is connected.";return e==="card"?o`<div class="sched-held">${t}</div>`:o`<div class="sched-note sched-note-held"><span class="sched-note-mark" aria-hidden="true">⚠️</span><span class="sched-note-msg">${t}</span></div>`}function Bn(e,t){return fe()?o`<p class="hint not-connected">No email provider connected, so ${e}. ${Ce()}${t}.</p>`:null}function Fn(){return Bn("a test can\u2019t be sent yet"," to send one")}function li(){return Bn("a confirmation can\u2019t be sent yet"," to add subscribers")}function Us(){return El()&&!fe()?o`<p class="hint daily-writes" id="schDailyWrites" role="status" hidden></p>`:null}function ci(){return Bn("this can\u2019t be scheduled yet"," to schedule it")}function di(){return Bn("this send is held when it fires, until one is"," to send it")}function Tt(e){return Yt()?`${e} (local stand-in: nothing is delivered)`:e}function ui(e){let t=Q.appConfig?.deployment;return t?si(t.archiveOrigin,t.archiveBasePath,e):null}var pi=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
|
|
3
|
-
`;var
|
|
4
|
-
`;var
|
|
5
|
-
`;var
|
|
6
|
-
`;var
|
|
7
|
-
`;var
|
|
8
|
-
`;var
|
|
9
|
-
`;var
|
|
10
|
-
`;var
|
|
11
|
-
`;var
|
|
12
|
-
`;var
|
|
13
|
-
`;var
|
|
14
|
-
`;var
|
|
15
|
-
`;var
|
|
16
|
-
`;var
|
|
17
|
-
`;var
|
|
18
|
-
`;var
|
|
19
|
-
`;var
|
|
20
|
-
`;var
|
|
21
|
-
`;var
|
|
22
|
-
`;var
|
|
23
|
-
`;var
|
|
24
|
-
`;var
|
|
25
|
-
`;var
|
|
26
|
-
`;var
|
|
27
|
-
`;var
|
|
28
|
-
`;var
|
|
29
|
-
`;var
|
|
30
|
-
`;var
|
|
31
|
-
`;var
|
|
32
|
-
`;var
|
|
33
|
-
`;var
|
|
34
|
-
`;var
|
|
35
|
-
`;var
|
|
2
|
+
"use strict";(()=>{function Ks(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var he=Ks("app"),Gs=Ks("identity"),Ys=Ks("toasts"),vi=document.getElementById("navToggle"),Ws=document.getElementById("navScrim");function nn(e){document.body.classList.toggle("nav-open",e),vi?.setAttribute("aria-expanded",e?"true":"false"),Ws&&(Ws.hidden=!e)}var tn=document.getElementById("navCollapse"),Vs=null;function Dl(){return window.innerWidth<540?"full":Vs==="collapsed"?"rail":Vs==="expanded"?"full":window.innerWidth<=1024?"rail":"full"}function qs(){let e=Dl();if(document.documentElement.dataset.nav=e,!tn)return;let t=e!=="rail",n=t?"Collapse sidebar":"Expand sidebar";tn.setAttribute("aria-expanded",t?"true":"false"),tn.setAttribute("aria-label",n),tn.setAttribute("title",n)}var js=0,gi=0;function yi(){vi?.addEventListener("click",()=>nn(!document.body.classList.contains("nav-open"))),Ws?.addEventListener("click",()=>nn(!1)),document.querySelector(".sidebar")?.addEventListener("click",e=>{e.target instanceof Element&&e.target.closest("a")&&nn(!1)}),tn?.addEventListener("click",()=>{Vs=document.documentElement.dataset.nav!=="rail"?"collapsed":"expanded",qs()}),window.addEventListener("resize",()=>{document.body.classList.add("nav-resizing"),clearTimeout(gi),gi=setTimeout(()=>{document.body.classList.remove("nav-resizing")},200),!js&&(js=requestAnimationFrame(()=>{js=0,qs()}))}),qs()}var In=null,Nn=null;function Q(e){In?.abort(),Nn=null;let t=new AbortController;In=t;let n=document.createElement("div");n.className="view",he.replaceChildren(n);let s;try{s=Promise.resolve(e(n,t.signal))}catch(i){s=Promise.reject(i)}return s.then(i=>{!t.signal.aborted&&i&&(Nn=i)},i=>{if(!t.signal.aborted)throw i})}function Ht(){In?.abort(),In=null,Nn=null,he.replaceChildren()}function Bn(){return Nn}function pt(e,t,n){if(n.aborted)return;let s=setInterval(t,e);n.addEventListener("abort",()=>clearInterval(s),{once:!0})}function mt(e,t,n){if(n.aborted)return;let s=()=>clearTimeout(i),i=setTimeout(()=>{n.removeEventListener("abort",s),t()},Math.max(0,e-Date.now()));n.addEventListener("abort",s,{once:!0})}function wi(e,t,n){let s=()=>{if(n.aborted)return;let i=()=>clearTimeout(a),a=setTimeout(async()=>{n.removeEventListener("abort",i);let r=!0;try{r=await t()??!0}catch(l){Fl(l)||reportError(l)}r!==!1&&s()},e);n.addEventListener("abort",i,{once:!0})};s()}function ft(e,t){if(e.aborted){t();return}e.addEventListener("abort",t,{once:!0})}function Fl(e){return e instanceof Error&&(e.name==="AbortError"||e.name==="ApiError")}function c(e,t=document){let n=t.querySelector(e);if(!n)throw new Error(`no element matches ${e}`);return n}function I(e,t=document){return[...t.querySelectorAll(e)]}var Ul={"&":"&","<":"<",">":">",'"':""","'":"'"};function ae(e){return String(e).replace(/[&<>"']/g,t=>Ul[t]??t)}var ht=class{markup;constructor(t){this.markup=t,Object.freeze(this)}toString(){return this.markup}};function $i(e){return e instanceof ht?e.markup:e==null||e===!1?"":Array.isArray(e)?e.map($i).join(""):ae(e)}var ql=/=\s*$/;function o(e,...t){let n=Si(e,0);for(let s=0;s<t.length;s++){let i=t[s];if(!(i instanceof ht)&&ql.test(n))throw new Error("html: an interpolation in an unquoted attribute value cannot be made safe; quote it");n+=$i(i)+Si(e,s+1)}return new ht(n)}function Si(e,t){let n=e[t];if(n===void 0)throw new Error(`html: invalid escape sequence in template text near ${JSON.stringify(e.raw[t])}`);return n}function j(e){return new ht(e)}function b(e,t){if(!(t instanceof ht))throw new Error("setHtml: expected Html; build it with html`\u2026` or vouch for it with unsafeHtml()");e.innerHTML=t.markup}var jl="https://getkestrel.dev/docs/access/";function On(e){if(!e||typeof e!="object")return!1;let t=e;return t.error==="unauthorized"&&t.reason==="access_not_configured"}function Dn(){he.querySelector(".access-wall")||(Ht(),document.body.classList.add("signed-out"),b(he,o`<div class="card auth-wall access-wall"><h2 tabindex="-1">The editor is locked until you set up Access</h2><p>Until Cloudflare Access is in front of the editor, the app can’t tell who is asking, so it lets no one in. On a new deployment, that’s expected. Your public pages work as usual.</p><p class="hint"><a href="${jl}" target="_blank" rel="noopener">Lock the dashboard with Access <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p><button id="access-reload" class="primary">Reload</button></div>`),c("#access-reload",he).onclick=()=>location.reload(),c(".access-wall h2",he).focus())}function Fn(e){if(!e||typeof e!="object")return null;let t=e;return t.error==="schema_behind"&&typeof t.message=="string"?{message:t.message}:t.error!=="invalid_config"||typeof t.variable!="string"?null:{variable:t.variable,message:typeof t.message=="string"?t.message:`${t.variable} is refused`}}var Wl="https://getkestrel.dev/docs/configuration/",Vl={PROVIDER:"provider",ALLOW_FAKE_PROVIDER:"provider",APP_ORIGIN:"vars",AWS_REGION:"vars",FROM_ADDRESS:"from_address-and-sending_domain",SENDING_DOMAIN:"from_address-and-sending_domain",ARCHIVE_BASE_PATH:"archive_base_path",SES_MAX_SEND_RATE:"ses_max_send_rate",SUBREQUEST_BUDGET:"subrequest_budget",MIN_LEAD_SECONDS:"min_lead_seconds",ARCHIVE_ORIGIN:"archive_origin-and-media_public_base",MEDIA_PUBLIC_BASE:"archive_origin-and-media_public_base",NOTIFY_FROM:"notify_from",ACCESS_TEAM_DOMAIN:"secrets",ACCESS_AUD:"secrets",ACCESS_ALLOWED_EMAILS:"secrets",AWS_ACCESS_KEY_ID:"secrets",AWS_SECRET_ACCESS_KEY:"secrets",SNS_TOPIC_ARN:"secrets",SES_CONFIGURATION_SET:"secrets",SIMULATE_SENDS:"what-the-app-refuses"},Kl="what-the-app-refuses";function Gl(e){return`${Wl}#${Vl[e]??Kl}`}function Un(e){if(he.querySelector(".config-wall"))return;Ht(),document.body.classList.add("signed-out");let{variable:t}=e,n=t?"A setting needs fixing":"The database needs updating",s=t?o`<p class="hint"><a href="${Gl(t)}" target="_blank" rel="noopener">${t} in the Configuration reference <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a></p>`:"";b(he,o`<div class="card auth-wall config-wall" role="alert"><h2 tabindex="-1">${n}</h2><p>Kestrel won’t run until it’s fixed, and nothing is sent in the meantime.</p><p class="config-wall-problem">${e.message}</p>${s}<button id="config-reload" class="primary">Reload</button></div>`),c("#config-reload",he).onclick=()=>location.reload(),c(".config-wall h2",he).focus()}var qn="kestrel_token",X={token:localStorage.getItem(qn)||"",session:null,appConfig:null};function Ei(e){X.token=(e||"").trim();try{X.token?localStorage.setItem(qn,X.token):localStorage.removeItem(qn)}catch{}}function zs(){return X.token?{Authorization:`Bearer ${X.token}`}:{}}function _i(){let e=X.session;if(e?.auth.mode==="access"){let t=e.principal,n=t.email||(t.kind==="service"?"Service token":"Signed in");b(Gs,o`<span class="who" title="${n}">${n}</span><a class="ghost" href="/cdn-cgi/access/logout" title="Sign out"><svg class="signout-icon" aria-hidden="true"><use href="#i-signout"/></svg><span class="signout-label">Sign out</span></a>`)}else b(Gs,o`<span class="who dev" title="Local dev: sign-in is skipped on localhost">Local dev</span>`)}function Zs(){he.querySelector(".reauth-wall")||(Ht(),document.body.classList.add("signed-out"),b(he,o`<div class="card auth-wall reauth-wall"><h2 tabindex="-1">Session expired</h2><p class="hint">Your access session ended. Sign in again to continue.</p><button id="reauth" class="primary">Sign in</button></div>`),c("#reauth",he).onclick=()=>location.reload(),c(".reauth-wall h2",he).focus())}async function xi(){try{let e=await fetch("/api/dev/token?kind=human");if(e.ok)return Ei((await e.json()).token),!0}catch{}return!1}async function ki(){try{return await fetch("/api/whoami",{headers:zs(),redirect:"manual"})}catch{return null}}async function Ti(){X.token||await xi();let e=await ki();if(!e?.ok&&X.token&&(Ei(""),await xi()&&(e=await ki())),e?.ok)return await e.json();let t=e?await e.json().catch(()=>null):null,n=Fn(t);return n?Un(n):On(t)?Dn():Zs(),null}var ee=class extends Error{constructor(n,s,i=null){super(n);this.status=s;this.data=i;this.name="ApiError"}status;data;get code(){let n=this.data;return typeof n?.error=="string"?n.error:null}get field(){let n=this.data;return typeof n?.field=="string"?n.field:null}},Yl="Not authorized. Sign in again.",zl=e=>/^[a-z_]+$/i.test(e.trim()),Zl=/^(Failed to fetch|NetworkError when attempting to fetch resource\.?|Load failed|The network connection was lost\.?|The Internet connection appears to be offline\.?)$/;function Js(e){return e instanceof TypeError&&Zl.test(e.message)}function q(e){if(e instanceof ee){let t=e.data?.message;return typeof t=="string"&&!zl(t)?t:e.status===401?e.message:e.status===404?"It isn\u2019t there any more. It may have been deleted elsewhere.":e.status>=500?"Something went wrong on the server. Try again in a moment.":e.status===429?"Too many requests at once. Try again in a moment.":"The server couldn\u2019t do that. Reload the page and try again."}return Js(e)?"Couldn\u2019t reach Kestrel. Check your connection and try again.":e instanceof Error?e.message:String(e)}async function Qs(e,t){let n={...zs(),...t.headers??{}},s=t.body;t.json!==void 0&&(n["content-type"]="application/json",s=JSON.stringify(t.json));let i=await fetch(e,{method:t.method??"GET",headers:n,body:s,signal:t.signal});if(i.status===401){let a=await i.json().catch(()=>null);throw On(a)?Dn():Zs(),new ee(Yl,401,a)}return i}function Jl(e){if(e&&typeof e=="object"){let t=e;if(typeof t.message=="string")return t.message;if(typeof t.error=="string")return t.error}}async function Xs(e){let t=await e.text(),n=null;try{n=t?JSON.parse(t):null}catch{}let s=Fn(n);s&&Un(s);let i=e.statusText||`Request failed (HTTP ${e.status})`;return new ee(Jl(n)??i,e.status,n)}async function H(e,t={}){let n=await Qs(e,t);if(!n.ok)throw await Xs(n);let s=await n.text();return s?JSON.parse(s):null}async function At(e,t={}){let n=await Qs(e,t);if(!n.ok)throw await Xs(n);return n.text()}async function jn(e,t={}){let n=await Qs(e,t);if(!n.ok)throw await Xs(n);let s=n.headers.get("content-disposition")??"",i=/filename="([^"]+)"/.exec(s)?.[1]??null;return{text:await n.text(),filename:i}}function eo(e){let t=e.trim(),n=t.lastIndexOf("<");if(n<0||!t.endsWith(">"))return"";let s=t.slice(0,n).trim();return s.length>=2&&s.startsWith('"')&&s.endsWith('"')?s.slice(1,-1).replace(/\\(.)/g,"$1").trim():s}function sn(e,t){return e||eo(t)}function to(e){let t=e?.settings.publication;return{name:sn(t?.name||"",e?.deployment.from_address??"")||"Your publication",tagline:t?.tagline||"",logoUrl:t?.logo_url||null}}function Wn(){let e=to(X.appConfig),t=document.getElementById("brandName"),n=document.getElementById("brandTagline"),s=document.getElementById("brandLogo");t&&(t.textContent=e.name),n&&(n.textContent=e.tagline,n.hidden=!e.tagline),s&&(e.logoUrl?(b(s,o`<img src="${e.logoUrl}" alt="">`),s.classList.remove("brand-logo-placeholder")):(s.textContent=(e.name.trim()[0]||"K").toUpperCase(),s.classList.add("brand-logo-placeholder")))}function Li(e,t,n){return`${e}${t}/${n}`}var Ql=["content","invalid_request"];function no(e){return e!==null&&Ql.includes(e)}var so={step:"Connect Amazon SES",guide:" (the setup guide walks through it)",after:", then deploy."};var bm=1800*1e3,Vn=300*1e3,gm=60*1e3,vm=1440*60*1e3;function Pt(e){let t=Math.round(e/1e3);if(t%60===0){let n=t/60;return`${n} minute${n===1?"":"s"}`}return`${t} seconds`}function Ri(e){return`Pick a time at least ${Pt(e)} from now, so there\u2019s time to cancel.`}var ym=10080*60*1e3,wm=3600*1e3;function Ci(){let e=X.appConfig?.deployment.build;if(!e?.version)return null;let t=e.tag_url?o`<a class="build-link" href="${e.tag_url}" target="_blank" rel="noopener">v${e.version}</a>`:o`v${e.version}`,n=e.commit_url?o`<a class="build-link" href="${e.commit_url}" target="_blank" rel="noopener">${e.sha}</a>`:o`${e.sha}`;return{version:t,sha:n}}function Kn(){return X.appConfig?.deployment.min_lead_ms??Vn}function Gn(){return Ri(Kn())}function Hi(){let e=Kn();if(e%6e4===0){let t=e/6e4;return`${t} to ${t+1} minutes`}return`${Pt(e)} to ${Pt(e+6e4)}`}function Yn(e,t=Date.now()){return e<t+Kn()}function zn(e=Date.now()){return new Date(e+Kn()+6e4)}function on(){return X.appConfig?.deployment.provider==="fake"}function ge(){return X.appConfig?.deployment.provider==="none"}function Xl(){return X.appConfig?.deployment.free_plan_limits===!0}function ec(){return ge()&&X.appConfig?.deployment.not_connected_preview===!0}var oo="#/docs/ses";function He(e){let t=e?o` class="${e}"`:null;return o`<a${t} href="${oo}">${so.step}</a>`}function io(){return ec()?o`Restart <code>npm run dev</code> without <code>SIMULATE_SENDS=not-connected</code> to send.`:o`${He()}${so.after}`}function Zn(e,t){let n=t===1;return o`No email provider connected, so nothing can be sent and ${e} ${n?"is":"are"} held, with no one marked unsent; ${n?"it goes out at its":"they go out at their"} first retry once one is connected. ${io()}`}function Jn(e,t){return ge()?o`<div class="health amber ${e}" role="status"><span class="health-dot">⚠️</span><div><span>${t}</span></div></div>`:null}function Qn(e){if(!ge())return null;let t="Will be held: no email provider is connected.";return e==="card"?o`<div class="sched-held">${t}</div>`:o`<div class="sched-note sched-note-held"><span class="sched-note-mark" aria-hidden="true">⚠️</span><span class="sched-note-msg">${t}</span></div>`}function Xn(e,t){return ge()?o`<p class="hint not-connected">No email provider connected, so ${e}. ${He()}${t}.</p>`:null}function es(){return Xn("a test can\u2019t be sent yet"," to send one")}function Ai(){return Xn("a confirmation can\u2019t be sent yet"," to add subscribers")}function ro(){return Xl()&&!ge()?o`<p class="hint daily-writes" id="schDailyWrites" role="status" hidden></p>`:null}function Pi(){return Xn("this can\u2019t be scheduled yet"," to schedule it")}function Mi(){return Xn("this send is held when it fires, until one is"," to send it")}function Mt(e){return on()?`${e} (local stand-in: nothing is delivered)`:e}function Ii(e){let t=X.appConfig?.deployment;return t?Li(t.archive_origin,t.archive_base_path,e):null}var Ni=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
|
|
3
|
+
`;var Bi=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M272-200v-560h221q65 0 120 40t55 111q0 51-23 78.5T602-491q25 11 55.5 41t30.5 90q0 89-65 124.5T501-200H272Zm121-112h104q48 0 58.5-24.5T566-372q0-11-10.5-35.5T494-432H393v120Zm0-228h93q33 0 48-17t15-38q0-24-17-39t-44-15h-95v109Z"/></svg>
|
|
4
|
+
`;var Oi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z"/><path d="M4 21V5"/><path d="M9 8h6"/></svg>
|
|
5
|
+
`;var Di=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v8a4 4 0 0 0 4 4h8"/><path d="m14 11 4 4-4 4"/></svg>
|
|
6
|
+
`;var Fi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
|
7
|
+
`;var Ui=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/></svg>
|
|
8
|
+
`;var qi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>
|
|
9
|
+
`;var ji=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M320-240 80-480l240-240 57 57-184 184 183 183-56 56Zm320 0-57-57 184-184-183-183 56-56 240 240-240 240Z"/></svg>
|
|
10
|
+
`;var Wi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
|
|
11
|
+
`;var Vi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/><path d="M12 16v4"/></svg>
|
|
12
|
+
`;var Ki=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
|
|
13
|
+
`;var Gi=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M360-280v-400h80v160h160v-160h80v400h-80v-160H440v160h-80Z"/></svg>
|
|
14
|
+
`;var Yi=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm0 440v-320l160 160-160 160Z"/></svg>
|
|
15
|
+
`;var zi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>
|
|
16
|
+
`;var Zi=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M440-280h80v-240h-80v240Zm68.5-331.5Q520-623 520-640t-11.5-28.5Q497-680 480-680t-28.5 11.5Q440-657 440-640t11.5 28.5Q463-600 480-600t28.5-11.5ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
|
17
|
+
`;var Ji=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M200-200v-100h160l120-360H320v-100h400v100H580L460-300h140v100H200Z"/></svg>
|
|
18
|
+
`;var Qi=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 360" aria-hidden="true"><path d="M290.028 216.064C285.698 218.264 280.838 219.394 275.978 219.344C281.268 225.044 284.128 232.924 283.708 240.694C283.278 248.844 279.388 256.514 274.268 262.874C269.148 269.244 262.808 274.494 256.608 279.814C237.148 296.474 218.248 314.404 195.328 325.854C178.568 334.224 160.148 338.864 143.458 347.374C140.148 349.064 136.898 350.904 133.418 352.224C129.938 353.544 126.178 354.324 122.498 353.794C121.718 353.684 120.938 353.504 120.228 353.184C119.508 352.864 118.848 352.374 118.408 351.734C117.918 351.024 117.708 350.154 117.718 349.304C117.728 348.454 117.948 347.614 118.268 346.824C118.567 346.054 118.967 345.339 119.371 344.618L119.418 344.534C128.788 327.894 140.118 312.454 150.358 296.344C160.598 280.224 169.848 263.174 174.428 244.634C176.258 237.264 177.328 229.644 176.738 222.074C176.138 214.504 173.808 206.964 169.308 200.854C164.588 194.434 157.728 189.874 150.538 186.434C139.628 181.204 127.768 178.284 115.858 176.154C93.9183 172.224 71.6083 170.884 49.4083 168.944C45.8483 168.634 42.2483 168.294 38.8783 167.124C34.7683 165.704 31.0683 163.004 28.7383 159.334C26.4183 155.664 25.5583 151.024 26.7483 146.844C27.6583 143.624 29.7283 140.784 32.3583 138.724C34.9883 136.654 38.1683 135.324 41.4383 134.634C44.7183 133.944 48.0983 133.874 51.4383 134.154C53.2683 134.304 55.0883 134.554 56.8983 134.824C72.2383 137.094 87.3783 140.484 102.548 143.644C115.468 146.334 128.498 148.864 141.688 149.224C154.878 149.574 168.338 147.654 180.178 141.824C196.548 133.764 208.758 118.514 215.298 101.474C224.258 78.094 223.028 52.174 220.398 27.284C219.788 21.484 219.108 15.614 220.058 9.854C220.178 9.124 220.328 8.38398 220.658 7.71398C220.988 7.05398 221.528 6.45399 222.228 6.19399C222.858 5.95399 223.568 6.01399 224.218 6.22399C224.858 6.43399 225.448 6.77399 226.028 7.12399C262.588 29.154 290.508 63.614 310.198 101.484C312.188 105.314 314.108 109.204 315.408 113.314C316.708 117.434 317.358 121.814 316.768 126.084C316.348 129.114 315.268 132.104 313.318 134.454C310.668 137.634 306.688 139.354 302.778 140.744C298.878 142.124 294.808 143.324 291.488 145.804C286.998 149.174 284.328 154.834 284.598 160.444C292.098 161.904 299.448 164.114 306.508 167.014C312.788 169.594 318.998 172.854 323.418 178.014C327.088 182.294 329.288 187.614 331.438 192.844C332.158 194.584 332.878 196.344 333.328 198.184C333.778 200.014 333.958 201.944 333.578 203.794C333.138 205.934 331.948 207.914 330.248 209.294C329.898 208.024 329.258 206.834 328.408 205.834C326.958 204.154 324.928 203.054 322.798 202.454C318.258 201.174 313.328 202.114 309.018 204.044C304.708 205.974 300.898 208.834 297.068 211.584C294.808 213.204 292.508 214.804 290.028 216.064Z"/></svg>
|
|
19
|
+
`;var Xi=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4 6h1M4 12h1M4 18h1"/></svg>
|
|
20
|
+
`;var er=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm200 160v-80h160q50 0 85-35t35-85q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H520Z"/></svg>
|
|
21
|
+
`;var tr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
|
22
|
+
`;var nr=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M120-80v-60h100v-30h-60v-60h60v-30H120v-60h120q17 0 28.5 11.5T280-280v40q0 17-11.5 28.5T240-200q17 0 28.5 11.5T280-160v40q0 17-11.5 28.5T240-80H120Zm0-280v-110q0-17 11.5-28.5T160-510h60v-30H120v-60h120q17 0 28.5 11.5T280-560v70q0 17-11.5 28.5T240-450h-60v30h100v60H120Zm60-280v-180h-60v-60h120v240h-60Zm180 440v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360Z"/></svg>
|
|
23
|
+
`;var sr=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M720-330q0 104-73 177T470-80q-104 0-177-73t-73-177v-370q0-75 52.5-127.5T400-880q75 0 127.5 52.5T580-700v350q0 46-32 78t-78 32q-46 0-78-32t-32-78v-370h80v370q0 13 8.5 21.5T470-320q13 0 21.5-8.5T500-350v-350q-1-42-29.5-71T400-800q-42 0-71 29t-29 71v370q-1 71 49 120.5T470-160q70 0 119-49.5T640-330v-390h80v390Z"/></svg>
|
|
24
|
+
`;var or=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>
|
|
25
|
+
`;var ir=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
26
|
+
`;var rr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
27
|
+
`;var ar=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92ZM362.5-517.5Q380-535 380-560t-17.5-42.5Q345-620 320-620t-42.5 17.5Q260-585 260-560t17.5 42.5Q295-500 320-500t42.5-17.5Zm360 0Q740-535 740-560t-17.5-42.5Q705-620 680-620t-42.5 17.5Q620-585 620-560t17.5 42.5Q655-500 680-500t42.5-17.5ZM680-560Zm-360 0Z"/></svg>
|
|
28
|
+
`;var lr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
|
|
29
|
+
`;var cr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>
|
|
30
|
+
`;var dr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3 11 14M22 3l-7 18-4-7-7-4 18-7z"/></svg>
|
|
31
|
+
`;var ur=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
|
|
32
|
+
`;var pr=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" aria-hidden="true"><path d="M360-200v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360ZM200-160q-33 0-56.5-23.5T120-240q0-33 23.5-56.5T200-320q33 0 56.5 23.5T280-240q0 33-23.5 56.5T200-160Zm0-240q-33 0-56.5-23.5T120-480q0-33 23.5-56.5T200-560q33 0 56.5 23.5T280-480q0 33-23.5 56.5T200-400Zm-56.5-263.5Q120-687 120-720t23.5-56.5Q167-800 200-800t56.5 23.5Q280-753 280-720t-23.5 56.5Q233-640 200-640t-56.5-23.5Z"/></svg>
|
|
33
|
+
`;var mr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M8 8l4-4 4 4"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
|
|
34
|
+
`;var fr=`<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
|
35
|
+
`;var Mc={article:j(Ni),bold:j(Bi),check:j(Fi),code:j(ji),copyout:j(Wi),editable:j(Ki),heading:j(Gi),indent:j(Yi),info:j(zi),"info-filled":j(Zi),italic:j(Ji),kestrel:j(Qi),lines:j(Xi),link:j(er),ol:j(nr),paperclip:j(sr),plus:j(ir),preview:j(rr),quote:j(ar),readonly:j(lr),search:j(cr),send:j(dr),settings:j(ur),ul:j(pr),upload:j(mr),book:j(Oi),branch:j(Di),"circle-check":j(Ui),"circle-minus":j(qi),desktop:j(Vi),mail:j(tr),phone:j(or),x:j(fr)};function N(e){return Mc[e]}function It(e,t,n){let s=(h,f)=>e===h?o`<a href="#/${h}" data-room="${h}" data-text="${f}" aria-current="page">${f}</a>`:o`<a href="#/${h}" data-room="${h}" data-text="${f}">${f}</a>`,i=Ci(),a=X.appConfig?.deployment.build.build_time,r=a?`Built ${new Date(a).toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}`:null,l=i?o`${i.version} <span aria-hidden="true">·</span> ${i.sha}`:null,d=l?r?o`<div class="rail-foot" title="${r}">${l}</div>`:o`<div class="rail-foot">${l}</div>`:null,u=l?r?o`<footer class="room-foot" title="${r}">${l}</footer>`:o`<footer class="room-foot">${l}</footer>`:null,m=t==null?o`<div class="room-body norail"><div class="room-main">${n}</div></div>`:o`<div class="room-body"><nav class="room-rail" aria-label="Contents"><div class="rail-inner">${t}${d}</div></nav><div class="room-main">${n}</div></div>`;return o`<div class="room">
|
|
36
36
|
<header class="room-bar">
|
|
37
37
|
<a class="room-back" href="#/dashboard" aria-label="Back to dashboard"><span aria-hidden="true">←</span><span class="room-back-label">Dashboard</span></a>
|
|
38
38
|
<div class="room-nav">
|
|
39
|
-
<a class="room-brand" href="#/docs">${
|
|
40
|
-
<nav class="room-switch" aria-label="Reference">${s("docs","Docs")}${s("reference","API")}</nav>
|
|
39
|
+
<a class="room-brand" href="#/docs">${N("kestrel")}<span>Kestrel</span></a>
|
|
40
|
+
<nav class="room-switch" aria-label="Reference">${s("docs","Docs")}${s("api-reference","API")}</nav>
|
|
41
41
|
<a class="room-close" href="#/dashboard" title="Back to publication" aria-label="Back to publication"><span aria-hidden="true">✕</span></a>
|
|
42
42
|
</div>
|
|
43
43
|
</header>
|
|
44
44
|
${m}
|
|
45
45
|
${u}
|
|
46
|
-
</div>`}function
|
|
46
|
+
</div>`}function hr(){he.addEventListener("click",e=>{let t=e.target instanceof Element?e.target.closest(".room-bar a[href^='#/']"):null;!t||t.getAttribute("href")!==location.hash||(e.preventDefault(),window.scrollTo(0,0))})}var Ic=/^(\/[A-Za-z0-9._~%-]+)+$/,Nc=/\/(?:\.|%2e){1,2}(?=\/|$)/i;function ao(e){return Ic.test(e)&&!Nc.test(e)}function br(e){let t=new URLSearchParams(e).get("to");return t&&ao(t)?`#${t}`:null}var Bc=/^(\d+)\.(\d+)$/;function Oc(e){return`${e.seq}.${e.at}`}function gr(e){let t=Bc.exec(e);if(!t?.[1]||!t[2])return null;let n=Number(t[1]),s=Number(t[2]);return Number.isSafeInteger(n)&&Number.isSafeInteger(s)?{seq:n,at:s}:null}function ts(e,t){let n=gr(e),s=gr(t);return!n||!s?n?e:t:Oc({seq:Math.min(n.seq,s.seq),at:Math.min(n.at,s.at)})}function et(e){switch(e.status){case"scheduled":return e.phase==="due"?"due":"scheduled";case"sending":return"sending";case"sent":return e.phase==="complete"?"complete":"sent";default:return"canceled"}}var Dc=3e3,Fc=6e4,Uc=3e4,Fe=new Set,ns=null,bt=null,Ge,Xe=null,rn=!1,lo=!1,ss=0;function os(e){try{e()}catch(t){reportError(t)}}function co(e,t){return e&&t?ts(e,t):e??t}function vr(e){clearTimeout(Ge),Ge=void 0,!(!Fe.size||document.hidden)&&(Ge=setTimeout(an,e))}function an(){if(clearTimeout(Ge),Ge=void 0,!Fe.size)return;if(Xe){rn=!0;return}let e=co(ns,bt);bt=null;let t=[...Fe],n=new AbortController;Xe=n;let s=setTimeout(()=>n.abort(),Uc),i=e?`?since=${encodeURIComponent(e)}`:"";H(`/api/sends/feed${i}`,{signal:n.signal}).then(a=>{if(clearTimeout(s),Xe===n){Xe=null,ss=0,ns=a.cursor;for(let r of t)Fe.has(r)&&r.take(a);if(rn){rn=!1,an();return}vr(Math.max(0,a.read_again_at-a.now))}},a=>{if(clearTimeout(s),Xe===n){if(Xe=null,rn=!1,a instanceof ee&&a.code==="cursor_ahead"){let r=[...Fe];for(let l of r)Sr(l);for(let l of r)os(()=>l.stale());return}a instanceof ee&&a.status===400?ns=null:bt=co(bt,e),ss+=1,vr(Math.min(Dc*2**(ss-1),Fc))}})}function qc(){lo||(lo=!0,queueMicrotask(()=>{lo=!1,an()}))}function yr(){document.hidden?(clearTimeout(Ge),Ge=void 0):an()}function wr(e,t,n){n.aborted||(Fe.add(e),bt=co(bt,t),Fe.size===1&&document.addEventListener("visibilitychange",yr),n.addEventListener("abort",()=>Sr(e),{once:!0}),qc())}function Sr(e){Fe.delete(e),!Fe.size&&(clearTimeout(Ge),Ge=void 0,Xe?.abort(),Xe=null,rn=!1,ss=0,ns=null,bt=null,document.removeEventListener("visibilitychange",yr))}function Nt(e,t,n){let s=new Map(e.sends.map(i=>[i.id,et(i)]));wr({take(i){if(!i.sends.length&&!i.removed.length)return;let a=[];for(let l of i.sends){let d=et(l),u=s.get(l.id)??null;u!==d&&a.push({send:l,from:u,to:d}),s.set(l.id,d)}let r=i.removed.map(l=>l.id);for(let l of r)s.delete(l);os(()=>t.update({sends:i.sends,changes:a,removed:r}))},stale:()=>t.stale()},e.cursor,n)}function ln(e,t,n){let s=e.send.id,i=et(e.send);wr({take(a){if(a.removed.some(u=>u.id===s)){os(()=>t.removed());return}let r=a.sends.find(u=>u.id===s);if(!r)return;let l=et(r),d=l!==i?{send:r,from:i,to:l}:null;i=l,os(()=>t.update(r,d))},stale:()=>t.stale()},e.cursor,n)}function Ue(){Fe.size&&an()}function gt(e){return e.trim().toLowerCase()}function vt(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function W(e){if(!e)return"\u2014";let t=new Date(e);return t.toLocaleString(void 0,{year:t.getFullYear()===new Date().getFullYear()?void 0:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function is(e){let t=new Set,n=[];for(let s of String(e||"").split(/[\n,]+/)){let i=gt(s);vt(i)?t.add(i):i&&n.push(s.trim())}return{valid:[...t],invalid:n}}function rs(e){return e.length?`Not ${e.length===1?"an email address":"email addresses"}: ${e.join(", ")}`:null}function as(e,t,n){let s=new Map,i=[];for(let r of n)r.message?s.set(r.message,[...s.get(r.message)??[],r.email]):i.push(r.email);if(!s.size)return e?`Sent to ${e} of ${t}. Not sent to ${i.join(", ")}. Send again to try those.`:"Not sent. Send again to try.";let a=[...s].map(([r,l])=>t>1?`${l.join(", ")}: ${r}`:r);return i.length&&a.push(`Not sent to ${i.join(", ")}.`),e?`Sent to ${e} of ${t}. ${a.join(" ")} Send again goes only to those.`:a.join(" ")}function yt(e){let t=n=>String(n).padStart(2,"0");return`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}T${t(e.getHours())}:${t(e.getMinutes())}`}function cn(e,t=!1){let n=e-Date.now();if(t||n<=0)return"Preparing to send\u2026";let s=Math.floor(n/1e3),i=Math.floor(s/60),a=Math.floor(i/60);if(s<300)return i>0?`Sends in ${i}m ${String(s%60).padStart(2,"0")}s`:`Sends in ${s}s`;if(i<60)return`Sends in ${i}m`;if(a<24){let l=i%60;return l>0?`Sends in ${a}h ${l}m`:`Sends in ${a}h`}let r=Math.round(a/24);return`Sends in ${r} day${r===1?"":"s"}`}function ls(e){return`Missed its fire time \xB7 ${Math.max(0,Math.floor((Date.now()-e)/6e4))} min late`}var dn=e=>Math.max(0,Math.min(100,Math.round(e||0)));function cs(e){if(e==null||!Number.isFinite(e)||e<=0)return"\u2014";let t=Math.round(e/1e3);if(t<90)return`${t}s`;let n=Math.round(t/60);return n<90?`${n} min`:`${Math.round(n/60)} hr`}function wt(e,t){return t.email_only?o`<span class="title-with-label"><span class="title-text">${e}</span><span class="email-only-mark">${N("mail")}Email only</span></span>`:e}function ds(e){return e.email_only?o` · <span class="sched-email-only">Email only</span>`:null}function us(e){return o`<span class="countdown" data-fire="${e.fire_at}"${e.phase==="due"&&o` data-due`}${Le(e,"missed")&&o` data-missed`}></span>`}function ps(e,t){let n=0,s=()=>{let i=Date.now(),a=Number.POSITIVE_INFINITY,r=l=>{l>i&&l<a&&(a=l)};for(let l of I("[data-closes]",e)){let d=Number(l.dataset.closes);l.hidden=i>=d,r(d)}for(let l of I("[data-fire]",e)){let d=Number(l.dataset.fire),u=l.dataset.missed!==void 0;l.textContent=u?ls(d):cn(d,l.dataset.due!==void 0),l.classList.toggle("countdown-missed",u),r(d)}a-i<=1e3&&a!==n&&(n=a,mt(a,s,t))};return pt(1e3,s,t),s}var Le=(e,t)=>e.conditions.some(n=>n.kind===t);function fe(e,t){return e.conditions.find(n=>n.kind===t)}var Ye=(e,t)=>e.actions.some(n=>n.name===t);function Bt(e,t){let n=new Set(e.map(s=>s.id));return[...e.map(s=>t.get(s.id)??s),...[...t.values()].filter(s=>!n.has(s.id))]}function un(e){return Le(e,"wedged")||Le(e,"refused")}function Ot(e){let t=e.delivered||0,n=[];e.complained&&n.push(`${e.complained.toLocaleString()} complained`),e.bounced&&n.push(`${e.bounced.toLocaleString()} bounced`),e.unsent&&n.push(`${e.unsent.toLocaleString()} unsent`);let s=n.length?o`<span class="muted delivered-note">${n.map((i,a)=>o`${a?", ":""}<span class="delivered-kind">${i}</span>`)}</span>`:null;return o`<span class="n">${t.toLocaleString()}</span>${s}`}function Dt(e){if(e===null)return"it is retried on its own";let t=e-Date.now();return t>0?`next retry in ${cs(t)}`:"next retry due now"}function ms(e,{stuckNote:t=!0}={}){let n=e.counts,s=n.accepted+n.delivered+n.bounced+n.complained,i=n.delivered+n.bounced+n.complained,a=e.audience.count,r=a>0?100*s/a:0,l=e.dispatch.eta_ms?` \xB7 ~${cs(e.dispatch.eta_ms)} left`:"",d=t?fe(e,"stuck"):void 0,u=e.provider.halt,m=e.phase==="backing_off"&&u?.reason==="unavailable",h=m?"Backing off":"Sending",f=m?` \xB7 provider unavailable, ${Dt(u.retry_at)}`:"";return o`<div class="card spread clickable active-card" data-watch="${e.id}">
|
|
47
47
|
<div class="active-main">
|
|
48
48
|
<a class="card-link active-subj" href="#/sent/${e.id}">${e.subject||o`<em>untitled</em>`}</a>
|
|
49
|
-
<div class="active-bar"><div class="active-fill" style="width:${
|
|
50
|
-
<div class="muted active-stat">${
|
|
49
|
+
<div class="active-bar"><div class="active-fill" style="width:${dn(r)}%"></div></div>
|
|
50
|
+
<div class="muted active-stat">${h}: ${s.toLocaleString()} of ${a.toLocaleString()} accepted${i?` \xB7 ${i.toLocaleString()} confirmed`:""}${l}${f}</div>
|
|
51
51
|
${d?o`<div class="active-stuck">${d.message}</div>`:null}
|
|
52
52
|
</div>
|
|
53
53
|
<a class="ghost-link" href="#/sent/${e.id}">Watch →</a>
|
|
54
|
-
</div>`}function
|
|
54
|
+
</div>`}function Ft(e){let t=new URLSearchParams;e.status&&t.set("status",e.status),e.suppressed&&t.set("suppressed",e.suppressed),e.failures&&t.set("failures",e.failures);let n=(e.search||"").trim();return n&&t.set("search",n),e.sort&&(t.set("sort",e.sort),t.set("dir",e.dir??"desc")),t.set("limit",String(e.limit)),t.set("offset",String(e.offset)),t.toString()}function Ut(e){let t=e.statuses?.length?o`<select class="lt-status" aria-label="Filter by status"><option value="${e.allValue||""}">All statuses</option>${e.statuses.map(i=>o`<option value="${i.value}">${i.label}</option>`)}</select>`:null,n=e.suppressible?o`<label class="lt-toggle"><input type="checkbox" class="lt-suppressed"><span>Suppressed</span></label>`:null,s=e.failures?o`<label class="lt-toggle"><input type="checkbox" class="lt-failures"><span>With delivery failures</span></label>`:null;return o`<div class="list-toolbar">
|
|
55
55
|
<input class="lt-search" type="search" placeholder="${e.searchPlaceholder||"Search\u2026"}" aria-label="Search" autocomplete="off">
|
|
56
56
|
<div class="lt-filters">${t}${n}${s}</div>
|
|
57
|
-
</div>`}function
|
|
58
|
-
${
|
|
57
|
+
</div>`}function qt(e,t,n){let s=e.querySelector(".lt-search"),i=e.querySelector(".lt-status"),a=e.querySelector(".lt-suppressed"),r=e.querySelector(".lt-failures");if(s){s.value=t.search||"";let l;s.oninput=()=>{clearTimeout(l),l=setTimeout(()=>{t.search=s.value,t.offset=0,n()},250)}}i&&(i.value=t.status||"",i.onchange=()=>{t.status=i.value,t.offset=0,n()}),a&&(a.checked=t.suppressed==="only",a.onchange=()=>{t.suppressed=a.checked?"only":"",t.offset=0,n()}),r&&(r.checked=t.failures==="only",r.onchange=()=>{t.failures=r.checked?"only":"",t.offset=0,n()})}function Se(e,t,n,s){if(!t)return s?o`<th class="${s}">${e}</th>`:o`<th>${e}</th>`;let i=n.sort===t,a=i?n.dir==="asc"?"\u2191":"\u2193":"",r=`${s?`${s} `:""}sortable${i?" sorted":""}`;return o`<th class="${r}"><button type="button" class="th-sort" data-sort="${t}">${e}<span class="th-arrow" aria-hidden="true">${a}</span></button></th>`}function tt(e,t,n){for(let s of e.querySelectorAll(".th-sort"))s.onclick=()=>{let i=s.dataset.sort;t.sort===i?t.dir=t.dir==="asc"?"desc":"asc":(t.sort=i,t.dir="desc"),t.offset=0,n()}}function nt(e,t,n,s){let i=n?.limit??t.limit;if(!n||n.total<=i){b(e,o``);return}let a=n.offset+1,r=Math.min(n.offset+i,n.total),l=n.offset>0,d=n.offset+i<n.total;if(b(e,o`<div class="pager"><button type="button" class="pager-prev"${l?null:$r}>← Prev</button><span class="pager-range muted">${a}–${r} of ${n.total}</span><button type="button" class="pager-next"${d?null:$r}>Next →</button></div>`),l){let u=e.querySelector(".pager-prev");u&&(u.onclick=()=>{t.offset=Math.max(0,n.offset-i),s()})}if(d){let u=e.querySelector(".pager-next");u&&(u.onclick=()=>{t.offset=n.offset+i,s()})}}var $r=o` disabled`;var jc=3600,Wc=300,Vc=3,po=new WeakMap;function O(e){let t=[...Ys.querySelectorAll(".toast:not(.leaving)")],n=t.find(i=>i.textContent===e);if(n){xr(n);return}let s=document.createElement("div");s.className="toast",s.textContent=e,Ys.appendChild(s),requestAnimationFrame(()=>s.classList.add("show")),xr(s);for(let i of t.slice(0,t.length+1-Vc))Er(i)}function xr(e){clearTimeout(po.get(e)),po.set(e,window.setTimeout(()=>Er(e),jc))}function Er(e){clearTimeout(po.get(e)),e.style.height=`${e.offsetHeight}px`,e.offsetHeight,e.classList.add("leaving"),e.style.height="0",setTimeout(()=>e.remove(),Wc)}var ze=e=>o`<span class="badge ${e}">${e}</span>`,Kc=0,uo=new WeakMap;function _r(e){let t=document.createElement("div");t.className="field-error",t.id=`field-error-${++Kc}`,t.setAttribute("role","alert");let n=document.createElement("span");n.className="field-error-ico",n.setAttribute("aria-hidden","true"),n.textContent="!";let s=document.createElement("span");return s.textContent=e,t.append(n,s),t}function Me(e,t,{after:n}={}){uo.get(e)?.();let s=_r(t),i=e.getAttribute("aria-describedby");(n??e).after(s),e.setAttribute("aria-describedby",i?`${i} ${s.id}`:s.id),e.classList.add("is-invalid");let a=!1,r=()=>{a||(a=!0,s.remove(),e.classList.remove("is-invalid"),i===null?e.removeAttribute("aria-describedby"):e.setAttribute("aria-describedby",i),e.removeEventListener("input",r),e.removeEventListener("change",r),uo.delete(e))};return e.addEventListener("input",r),e.addEventListener("change",r),uo.set(e,r),r}function ue(e,t){let n=document.createElement("div");n.className="modal-backdrop",b(n,o`<div class="modal" role="dialog" aria-modal="true">${e}</div>`);let s=n.firstElementChild,i=document.activeElement instanceof HTMLElement?document.activeElement:null,a=m=>{n===[...document.querySelectorAll(".modal-backdrop")].pop()&&(m.key==="Escape"?u():m.key==="Tab"&&Yc(s,m))},r=null,l=()=>{r?.(),r=null},d=(m,h)=>{if(l(),h){r=Me(h,m),h.focus();return}let f=_r(m);f.classList.add("dialog-error");let g=s.querySelector(".actions");g?g.before(f):s.append(f),r=()=>f.remove()},u=()=>{let m=document.activeElement,h=n.contains(m)||!m||m===document.body;n.remove(),document.removeEventListener("keydown",a),t.removeEventListener("abort",u),h&&i?.isConnected&&i.focus()};return t.aborted?{el:n,close:u,error:d,clearError:l}:(document.body.appendChild(n),n.addEventListener("click",m=>{m.target===n&&u()}),document.addEventListener("keydown",a),t.addEventListener("abort",u,{once:!0}),queueMicrotask(()=>{n.isConnected&&!s.contains(document.activeElement)&&Tr(s)[0]?.focus()}),{el:n,close:u,error:d,clearError:l})}var Gc='a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function Tr(e){return[...e.querySelectorAll(Gc)].filter(t=>!t.closest("[hidden]"))}function Yc(e,t){let n=Tr(e),s=n[0],i=n[n.length-1];if(!s||!i){t.preventDefault();return}let a=document.activeElement;e.contains(a)?t.shiftKey&&a===s?(t.preventDefault(),i.focus()):!t.shiftKey&&a===i&&(t.preventDefault(),s.focus()):(t.preventDefault(),s.focus())}var kr=8;function zc(e){let t=getComputedStyle(e,"::after"),n=parseFloat(t.width)+parseFloat(t.paddingLeft)+parseFloat(t.paddingRight);if(!Number.isFinite(n))return;let s=e.getBoundingClientRect().left,i=document.documentElement.clientWidth,a=Math.min(0,Math.max(i-kr-n-s,kr-s));e.style.setProperty("--tip-x",`${Math.round(a)}px`)}function Lr(){for(let e of["pointerover","focusin"])document.addEventListener(e,t=>{let n=t.target instanceof Element?t.target.closest(".info"):null;n&&zc(n)});document.addEventListener("keydown",e=>{let t=document.activeElement;e.key==="Escape"&&t instanceof HTMLElement&&t.classList.contains("info")&&t.blur()})}var fs=[],mo=null,jt=null;function St(){let e=fs.some(t=>t.contains(document.activeElement));for(let t of fs)t.remove();fs=[],mo?.removeEventListener("abort",St),mo=null,jt?.setAttribute("aria-expanded","false"),e&&jt?.isConnected&&jt.focus(),jt=null}function Wt(e,t,n,{align:s="end"}={}){if(St(),n.aborted)return;let i=document.createElement("div");i.className="menu-overlay",i.onclick=St;let a=document.createElement("div");a.className="menu",a.setAttribute("role","menu");for(let l of t){let d=document.createElement("button");if(d.type="button",d.setAttribute("role","menuitem"),d.tabIndex=-1,d.className=`menu-item${l.danger?" danger-item":""}`,l.description){let u=document.createElement("span");u.className="menu-item-name",u.textContent=l.label;let m=document.createElement("span");m.className="menu-item-desc",m.textContent=l.description,d.append(u,m),a.classList.add("menu-described")}else d.textContent=l.label;d.onclick=()=>{St(),l.onClick()},a.appendChild(d)}a.addEventListener("keydown",l=>Zc(a,l)),document.body.appendChild(i),document.body.appendChild(a),fs=[i,a],mo=n,jt=e instanceof HTMLElement?e:null,jt?.setAttribute("aria-expanded","true"),n.addEventListener("abort",St,{once:!0});let r=e.getBoundingClientRect();a.style.top=`${r.bottom+window.scrollY+4}px`,a.style.left=`${(s==="start"?r.left:r.right-a.offsetWidth)+window.scrollX}px`,a.querySelector(".menu-item")?.focus()}function Zc(e,t){let n=[...e.querySelectorAll(".menu-item")],s=n.indexOf(document.activeElement),i=a=>{t.preventDefault(),n[(a+n.length)%n.length]?.focus()};t.key==="ArrowDown"?i(s+1):t.key==="ArrowUp"?i(s-1):t.key==="Home"?i(0):t.key==="End"?i(n.length-1):t.key==="Escape"?(t.preventDefault(),t.stopPropagation(),St()):t.key==="Tab"&&St()}async function G(e,t,n){let s=e.textContent;e.disabled=!0,t&&(e.textContent=t);try{return await n()}finally{e.isConnected&&(e.disabled=!1,e.textContent=s)}}function ne(e,t,n){b(e,o`<div class="error"><span>${t}</span><button class="ghost" data-retry>Retry</button></div>`);let s=e.querySelector("[data-retry]");s&&(s.onclick=n)}function Vt(e,t){return e.actions.find(n=>n.name===t)}function pn(e){let t=e.send;return t&&(t.status==="scheduled"||t.status==="sending")?t:null}var Jc=[{value:"draft",label:"Draft"},{value:"scheduled",label:"Scheduled"}],fo="draft,scheduled";function ho(e){return G(e,"Creating\u2026",async()=>{try{let{post:t}=await H("/api/posts",{method:"POST",json:{}});location.hash=`#/edit/${t.id}`}catch(t){O(q(t))}})}async function bo(e,t){let n={status:fo,search:"",sort:"",dir:"desc",offset:0,limit:50};b(e,o`<div class="spread page-head"><h1>Drafts</h1><button class="primary" id="newPost">New post</button></div>
|
|
58
|
+
${Ut({statuses:Jc,allValue:fo,searchPlaceholder:"Search subject\u2026"})}
|
|
59
59
|
<div id="list"><p class="muted">Loading…</p></div>
|
|
60
|
-
<div id="postsPager"></div>`);let s=c("#newPost",e);s.onclick=()=>zs(s);let i=c("#list",e),r=c("#postsPager",e),a=new Map,l=new Set,d=0,u=()=>m(!1);async function m(C){let L=++d;try{let I=await R(`/api/posts?${Ht(n)}`,{signal:t});if(L!==d)return null;let O=I.posts;if(a=new Map(O.map(g=>[g.id,g.fire_at])),l=new Set(O.flatMap(g=>g.active_send_id?[g.active_send_id]:[])),!O.length){let g=n.search||n.status&&n.status!==Ys;return h(i,o`<p class="muted">${g?"No drafts match.":"No drafts yet. Create your first draft."}</p>`),h(r,o``),I}h(i,o`<div class="table-wrap"><table class="list-table posts-table stacks has-actions"><colgroup><col><col class="c-status"><col class="c-date"><col class="c-date"><col class="c-act"></colgroup><thead><tr>${ve("Title","title",n)}${ve("Status",null,n)}${ve("Scheduled","scheduled",n)}${ve("Updated","updated",n)}<th></th></tr></thead><tbody>${O.map(g=>{let T=g.active_send_status==="sending",p=T?`#/sent/${g.active_send_id}`:`#/edit/${g.id}`;return o`<tr class="clickable" data-id="${g.id}" data-target="${p}"><td>${pt(o`<a href="${p}">${g.subject||o`<em>untitled</em>`}</a>`,g)}</td><td>${T?Xe("sending"):Xe(g.status)}</td><td class="muted when${g.fire_at?"":" empty"}">${g.fire_at?K(g.fire_at):"\u2014"}</td><td class="muted updated">${K(g.updated_at)}</td><td class="act"><button class="icon" data-menu="${g.id}" data-status="${T?"sending":g.status}" data-target="${p}" aria-label="Post actions" aria-haspopup="menu">⋯</button></td></tr>`})}</tbody></table></div>`),Je(i,n,u);for(let g of M("tr[data-id]",i))g.onclick=T=>{let p=T.target;p instanceof Element&&p.tagName!=="A"&&!p.closest("[data-menu]")&&g.dataset.target&&(location.hash=g.dataset.target)};for(let g of M("[data-menu]",i))g.onclick=T=>{T.stopPropagation();let p=g.dataset.status,x=g.dataset.target??"",_=[{label:p==="sending"?"Watch send":"Open",onClick:()=>{location.hash=x}}];if(p==="draft"&&g.dataset.menu){let D=g.dataset.menu;_.push({label:"Delete draft",danger:!0,onClick:()=>xc(D,u,t)})}Nt(g,_,t)};return Qe(r,n,I.page,u),I}catch(I){return!C&&L===d&&ie(i,W(I),()=>y?u():void k()),null}}let y=!1,v=C=>{y=!0,Lt({cursor:C.cursor,sends:C.posts.flatMap(L=>L.active_send_id&&L.active_send_status?[{id:L.active_send_id,status:L.active_send_status,phase:L.active_send_status==="sending"?"progressing":"scheduled"}]:[])},{update({sends:L,changes:I,removed:O}){(I.some(T=>a.has(T.send.post_id))||L.some(T=>T.status==="scheduled"&&a.has(T.post_id)&&a.get(T.post_id)!==T.fire_at)||O.some(T=>l.has(T)))&&m(!0)},stale:()=>X(Zs)},t)},k=async()=>{let C=await m(!1);C&&!y&&v(C)};Mt(e,n,u),await k()}function xc(e,t,n){let s=de(o`<h3>Delete draft?</h3><p class="hint">This permanently deletes the draft and its revisions. This can’t be undone.</p><div class="actions"><button type="button" id="dCancel">Cancel</button><button type="button" class="danger" id="dGo">Delete</button></div>`,n),i=c("#dGo",s.el);c("#dCancel",s.el).onclick=s.close,i.onclick=()=>z(i,"Deleting\u2026",async()=>{s.clearError();try{await R(`/api/posts/${e}`,{method:"DELETE"}),s.close(),N("Draft deleted"),t()}catch(r){s.error(W(r))}})}var ir={pending:"Subscribed but hasn\u2019t clicked the confirmation email.",confirmed:"Receives your posts. They confirmed here, or opted in at the service you imported them from.",unsubscribed:"Opted out, themselves or by asking you."},rr="The address isn\u2019t sent your posts, whatever its status. The status stays as it was, greyed while the suppression keeps it from taking effect.",ts={bounce:"bounced",import_bounce:"bounced",complaint:"complaint",import_complaint:"complaint",manual:"blocked",erased:"erased"},Js={bounced:"Mail to the address failed, here or at the service it was imported from.",complaint:"Marked as spam, here or at the service it was imported from.",blocked:"Stopped by hand, by you or at the service it was imported from. You can unblock it.",erased:"Erased at their request, then signed up again. Only their confirmation email is sent, and confirming lifts this."};var tn={unsubscribe:{label:"Unsubscribe",when:"When they ask you to take them off the list. They can rejoin by subscribing again."},block:{label:"Block",when:"When you decide yourself to stop mailing an address. Their status stays as it was, and you can unblock it."},erase:{label:"Erase",when:"When they ask you to delete their data. It can\u2019t be undone."}};var Ec={buttondown:"Buttondown",convertkit:"ConvertKit",kit:"Kit",kestrel:"Kestrel",mailchimp:"Mailchimp",mailerlite:"MailerLite",sendy:"Sendy",substack:"Substack",beehiiv:"beehiiv",ghost:"Ghost"};function nn(e){return e===""||e==="unknown"?null:Ec[e]??e.charAt(0).toUpperCase()+e.slice(1)}function Qs(e){return e==="unknown"?"opt-in unknown":`${e} opt-in`}function ns(e){return e.import_id!==null?`Imported \xB7 ${[nn(e.origin),Qs(e.opt_in)].filter(Boolean).join(", ")}`:e.confirmed_at!==null?"Double opt-in":null}function ar(e){if(!e)return null;try{return JSON.stringify(JSON.parse(e),null,2)}catch{return e}}function ss(e,t){let n=fe(),s=de(o`<h3>Add subscriber</h3><p class="hint">Starts the normal double opt-in: they get a confirmation email and won’t receive posts until they confirm.</p>${li()}${n?null:o`<label for="addEmail">Email address</label><input type="email" id="addEmail" placeholder="person@example.com">`}<div class="actions"><button type="button" id="aCancel">Cancel</button><button type="button" class="primary" id="aGo">Send confirmation</button></div>`,t),i=c("#aGo",s.el);if(c("#aCancel",s.el).onclick=s.close,i.disabled=n,n){c("#aCancel",s.el).focus();return}let r=c("#addEmail",s.el);r.focus(),i.onclick=()=>z(i,"Adding\u2026",async()=>{s.clearError();let a=ct(r.value);if(!dt(a)){s.error("Enter a valid email address",r);return}try{let l=u=>R("/api/subscribers",{method:"POST",json:u?{email:a,acknowledge_erasure:!0}:{email:a}}),d;try{d=await l(!1)}catch(u){let m=Tc(u);if(!m)throw u;if(!await _c(a,m.erased_at,t))return;d=await l(!0)}s.close(),N(Lc(a,d.action)),e()}catch(l){s.error(W(l),l instanceof J&&l.field==="email"?r:void 0)}})}function Tc(e){return e instanceof J&&e.status===409&&e.code==="erasure_acknowledgement_required"?e.data:null}function _c(e,t,n){return new Promise(s=>{let i=de(o`<h3>Send to an erased address?</h3><p class="hint"><strong>${e}</strong> was erased at the person’s request on ${K(t)}. Send a confirmation only if they’ve asked to join again. They rejoin only by confirming it, which lifts the erasure.</p><div class="actions"><button type="button" id="eaCancel">Cancel</button><button type="button" class="primary" id="eaGo">Send confirmation</button></div>`,n);if(!i.el.isConnected){s(!1);return}let r=!1;c("#eaCancel",i.el).onclick=i.close,c("#eaGo",i.el).onclick=()=>{r=!0,i.close()};let a=new MutationObserver(()=>{i.el.isConnected||(a.disconnect(),s(r))});a.observe(document.body,{childList:!0}),c("#eaCancel",i.el).focus()})}function Lc(e,t){switch(t){case"already_confirmed":return`${e} is already confirmed`;case"suppressed":return`${e} is suppressed, so no confirmation was sent`;case"recently_sent":return`A confirmation went to ${e} a few minutes ago; try again later`;default:return`Confirmation sent to ${e}`}}var Rc={pending:"Pending",confirmed:"Confirmed",unsubscribed:"Unsubscribed"},Cc={double:"Double",single:"Single",unknown:"Unknown"};function lr(e,t,n){let s=ar(e.consent_evidence),i=e.import_id!==null,r=de(o`<h3 class="sub-title">${e.email}</h3><p class="hint">${Rc[e.status]}</p>
|
|
61
|
-
<
|
|
62
|
-
<dt>How they joined</dt><dd>${ns(e)??(e.status==="pending"?"Signed up here, not confirmed yet":"Signed up here, never confirmed")}</dd>
|
|
63
|
-
${i?o`<dt>Origin</dt><dd>${nn(e.origin)??"Unknown"}</dd>
|
|
64
|
-
<dt>Opt-in</dt><dd>${Cc[e.opt_in]}</dd>
|
|
65
|
-
<dt>Import</dt><dd><code>${e.import_id??""}</code></dd>`:null}
|
|
66
|
-
<dt>Joined</dt><dd>${K(e.created_at)}</dd>
|
|
67
|
-
<dt>Confirmed</dt><dd>${K(e.confirmed_at)}</dd>
|
|
68
|
-
${e.unsubscribed_at?o`<dt>Left</dt><dd>${K(e.unsubscribed_at)}</dd>`:null}
|
|
69
|
-
<dt>Evidence</dt><dd>${s?o`<pre class="sub-evidence">${s}</pre>`:"None recorded"}</dd>
|
|
70
|
-
${n?o`<dt>Suppressed</dt><dd>${n.label}${n.detail?o`: ${n.detail}`:null}</dd>`:null}
|
|
71
|
-
</dl>
|
|
72
|
-
${i?o`<p class="hint sub-note">The origin, opt-in, confirmation date, and evidence are what the imported file said, not something Kestrel recorded itself.</p>`:null}
|
|
73
|
-
<div class="actions"><button type="button" id="vsClose">Close</button></div>`,t);c("#vsClose",r.el).onclick=r.close}function cr(e,t,n){let s=de(o`<h3>Unsubscribe this subscriber?</h3><p class="hint">Use this when <strong>${e.email}</strong> asks you to take them off the list. They stop receiving posts immediately, and can rejoin by subscribing again. To stop mailing them on your own judgment, block the address instead.</p><div class="actions"><button type="button" id="uCancel">Cancel</button><button type="button" class="danger" id="uGo">Unsubscribe</button></div>`,n),i=c("#uGo",s.el);c("#uCancel",s.el).onclick=s.close,i.onclick=()=>z(i,"Unsubscribing\u2026",async()=>{s.clearError();try{await R(`/api/subscribers/${e.id}/unsubscribe`,{method:"POST"}),s.close(),N(`Unsubscribed ${e.email}`),t()}catch(r){s.error(W(r))}})}function dr(e,t,n){let s=de(o`<h3>Block this address?</h3><p class="hint">Stops all mail to <strong>${e.email}</strong>, whatever their status, which stays as it is. Use this when you decide yourself to stop mailing them; if they asked to leave, unsubscribe them instead. You can unblock it later.</p><div class="actions"><button type="button" id="bCancel">Cancel</button><button type="button" class="danger" id="bGo">Block</button></div>`,n),i=c("#bGo",s.el);c("#bCancel",s.el).onclick=s.close,i.onclick=()=>z(i,"Blocking\u2026",async()=>{s.clearError();try{let r=await R("/api/suppressions",{method:"POST",json:{email:e.email}});s.close(),N(r.added?`Blocked ${e.email}`:`${e.email} was already suppressed (${ts[r.suppression.reason]})`),t()}catch(r){s.error(W(r))}})}function ur(e,t,n){let s=e.status==="confirmed"?"They\u2019re confirmed, so they\u2019ll receive your posts again.":"They\u2019ll receive posts again only once they\u2019re confirmed.",i=de(o`<h3>Unblock this address?</h3><p class="hint">Undoes the block on <strong>${e.email}</strong>. ${s}</p><div class="actions"><button type="button" id="ubCancel">Cancel</button><button type="button" class="primary" id="ubGo">Unblock</button></div>`,n),r=c("#ubGo",i.el);c("#ubCancel",i.el).onclick=i.close,r.onclick=()=>z(r,"Unblocking\u2026",async()=>{i.clearError();try{await R(`/api/suppressions/${encodeURIComponent(e.email)}`,{method:"DELETE"}),i.close(),N(`Unblocked ${e.email}`),t()}catch(a){i.error(W(a))}})}function pr(e,t,n){let s=de(o`<h3>Erase this subscriber?</h3><p class="hint">Deletes <strong>${e.email}</strong> and how they joined, and past sends show an erased recipient in its place. The address is kept only so it’s never mailed again. This can’t be undone. They can still rejoin by subscribing and confirming themselves.</p><div class="actions"><button type="button" id="eCancel">Cancel</button><button type="button" class="danger" id="eGo">Erase</button></div>`,n),i=c("#eGo",s.el);c("#eCancel",s.el).onclick=s.close,i.onclick=()=>z(i,"Erasing\u2026",async()=>{s.clearError();try{let r=await R(`/api/subscribers/${e.id}/erase`,{method:"POST"});s.close(),N(Hc(r)),t()}catch(r){s.error(W(r))}})}var Ac={bounce:"bounce",import_bounce:"bounce",complaint:"complaint",import_complaint:"complaint",manual:"block"};function Hc({erased:e}){return e.suppression==="erased"?`Erased ${e.email}`:`Erased ${e.email}. Its ${Ac[e.suppression]??"suppression"} still stands`}var Pc=1500;function Me(e=!1,t="Copy"){return o`<span>${t}</span>${H(e?"check":"copyout")}`}function sn(e,t){return o`<button type="button" class="copy-btn" data-copy="${e}" aria-label="${t}">${Me()}</button>`}function Ot(e,t,n){return o`<div class="block"><div class="block-head"><span class="block-label">${e}</span>${sn(n,`Copy ${e}`)}</div><pre><code>${t}</code></pre></div>`}var Xs=new WeakMap;async function on(e,t){try{await navigator.clipboard.writeText(t)}catch{N("Couldn\u2019t copy to clipboard");return}let n=Xs.get(e),s=n?.label??e.getAttribute("aria-label")??"Copy",i=e.dataset.copyWord;window.clearTimeout(n?.timer),h(e,Me(!0,i)),e.setAttribute("aria-label","Copied"),e.classList.add("copied");let r=window.setTimeout(()=>{h(e,Me(!1,i)),e.setAttribute("aria-label",s),e.classList.remove("copied"),Xs.delete(e)},Pc);Xs.set(e,{label:s,timer:r})}function et(e){e.addEventListener("click",t=>{let n=t.target instanceof Element?t.target.closest("[data-copy]"):null;n&&e.contains(n)&&on(n,n.dataset.copy??"")})}function Mc(e,t=!1){let n=[],s=e.filter(p=>Te(p,"missed"));s.length&&n.push({level:"red",text:`${s.length} scheduled send${s.length===1?"":"s"} passed the fire time without going out.`});let i=e.flatMap(p=>{let x=be(p,"wedged");return x?[{s:p,count:x.count}]:[]}),[r]=i;if(r){let p=i.reduce((_,D)=>_+D.count,0),x=p===1?"delivery":"deliveries";n.push({level:"red",text:i.length===1?o`<a href="#/sent/${r.s.id}">${r.s.subject}</a> has ${p} ambiguous ${x} awaiting a decision; resolve ${p===1?"it":"them"} on its page.`:o`${p} ambiguous ${x} across <a href="#/sent">${i.length} sends</a> await a decision; resolve them on the Sent page.`})}let a=fe(),l=e.flatMap(p=>{let x=be(p,"refused");return x?[{s:p,c:x}]:[]}),d=a?l.filter(p=>p.c.cause==="not_connected"):[],u=l.filter(p=>!d.includes(p)),m=p=>p.length===1&&p[0]?o`<a href="#/sent/${p[0].s.id}">${p[0].s.subject}</a>`:o`<a href="#/sent">${p.length} sends</a>`;d.length&&n.push({level:"red",text:o`<span class="dash-not-connected">${Nn(m(d),d.length)}</span>`});let[y]=u;y&&n.push({level:"red",text:o`${m(u)}: ${y.c.message}`});let v=e.filter(p=>Te(p,"stuck")&&!en(p)),[k]=v;k&&n.push({level:"amber",text:v.length===1?o`<a href="#/sent/${k.id}">${k.subject}</a>: ${be(k,"stuck")?.message}`:o`<a href="#/sent">${v.length} sends</a> have handed off no one for a while, with no retry or provider problem to explain it.`});let C=e.flatMap(p=>{let x=be(p,"provider_unavailable");return x?.severity==="warn"?[{s:p,c:x}]:[]}),[L]=C;L&&n.push({level:"amber",text:o`<a href="#/sent/${L.s.id}">${L.s.subject}</a>: ${L.c.message}`});let I=e.flatMap(p=>{let x=be(p,"bounce_spike");return x?[{s:p,c:x}]:[]}),[O]=I;O&&n.push({level:"amber",text:o`<a href="#/sent/${O.s.id}">${O.s.subject}</a>: ${O.c.message}`});let g=e.flatMap(p=>{let x=be(p,"no_receipts");return x?[{s:p,c:x}]:[]}),[T]=g;return T&&n.push({level:"amber",text:g.length===1?o`<a href="#/sent/${T.s.id}">${T.s.subject}</a>: ${T.c.message}`:o`<a href="#/sent">${g.length} sends</a> have had no delivery receipt long after they went out. The provider's events may not be reaching this app, so its bounces and complaints suppress no one.`}),a&&!d.length&&!t&&n.push({level:"amber",text:o`<span class="dash-not-connected">No email provider connected, so nothing can be sent yet. ${Ce()} to start sending.</span>`}),n}function Ic(e){if(!e.length)return o``;let t=e.some(n=>n.level==="red")?"red":"amber";return o`<div class="health ${t}"><span class="health-dot">⚠️</span><div>${e.map(n=>o`<div>${n.text}</div>`)}</div></div>`}var mr=e=>e.filter(t=>t.status==="sending"&&!en(t));async function eo(e,t){let n=()=>{t.aborted||X(eo)};h(e,o`<div class="dash" id="dash"><p class="muted">Loading…</p></div>`);let s=c("#dash",e),i,r,a;try{let[S,B,te]=await Promise.all([R("/api/posts?limit=200",{signal:t}),R("/api/sends?limit=200",{signal:t}),R("/api/subscribers",{signal:t})]);i=S.posts,r=B,a=te.counts}catch(S){ie(s,W(S),n);return}let l=r.sends,d=new Map,u=Ns(Q.appConfig),m=Q.appConfig?.deployment??null,y=()=>l.some(S=>(S.status==="sending"||S.status==="sent")&&!S.email_only),v=()=>({named:!!Q.appConfig?.settings.publication?.name?.trim(),onList:a.confirmed+a.pending,archived:y()});if(!i.length){h(s,o`<div class="dash-head"><div><h1>${u.name}</h1>${u.tagline?o`<p class="muted dash-tagline">${u.tagline}</p>`:null}<p class="muted">Let’s get your first post out the door.</p></div></div>${kr(u,m,v(),"checklist")}<section class="dash-section"><h2>API access</h2>${$r(!1)}</section>`),Sr(s,n,t);return}let k=()=>!y(),C=()=>k()?kr(u,m,v(),"card"):o``,L=()=>Ic(Mc(Rt(l,d),k()&&fe())),I=[{label:"Confirmed",emph:!0,v:a.confirmed,filter:"confirmed"},{label:"Pending",v:a.pending,filter:"pending"},{label:"Unsubscribed",v:a.unsubscribed,filter:"unsubscribed"},{label:"Suppressed",v:a.suppressed,filter:"suppressed"}],O=o`<div class="tiles">${I.map(S=>o`<a class="tile${S.emph?" tile-emph":""}" href="#/subscribers/${S.filter}"><span class="tile-n">${S.v}</span><span class="tile-label">${S.label}${S.sub?o`<span class="tile-sub">${S.sub}</span>`:null}</span></a>`)}</div>`,g=i.filter(S=>S.status==="draft").slice(0,5),T=g.length?o`<div class="table-wrap"><table class="dash-drafts"><tbody>${g.map(S=>o`<tr class="clickable" data-id="${S.id}"><td>${pt(o`<a href="#/edit/${S.id}">${S.subject||o`<em>untitled</em>`}</a>`,S)}</td><td class="muted edited">edited ${K(S.updated_at)}</td></tr>`)}</tbody></table></div>`:o`<p class="muted">No drafts in progress.</p>`,p=m?.appOrigin||location.origin,x=o`<div class="card pub-card">
|
|
74
|
-
<div class="pub-row"><code class="pub-val">${p}</code>${sn(p,"Copy the publication\u2019s address")}</div>
|
|
60
|
+
<div id="postsPager"></div>`);let s=c("#newPost",e);s.onclick=()=>ho(s);let i=c("#list",e),a=c("#postsPager",e),r=new Map,l=0,d=()=>u(!1);async function u(g){let w=++l;try{let E=await H(`/api/posts?${Ft(n)}`,{signal:t});if(w!==l)return null;let M=E.posts;if(r=new Map(M.map(A=>[A.id,pn(A)])),!M.length){let A=n.search||n.status&&n.status!==fo;return b(i,o`<p class="muted">${A?"No drafts match.":"No drafts yet. Create your first draft."}</p>`),b(a,o``),E}b(i,o`<div class="table-wrap"><table class="list-table posts-table stacks has-actions"><colgroup><col><col class="c-status"><col class="c-date"><col class="c-date"><col class="c-act"></colgroup><thead><tr>${Se("Title","subject",n)}${Se("Status",null,n)}${Se("Scheduled","send.fire_at",n)}${Se("Updated","updated_at",n)}<th></th></tr></thead><tbody>${M.map(A=>{let T=pn(A),$=T?.status==="sending",p=T&&$?`#/sent/${T.id}`:`#/edit/${A.id}`,x=T?.fire_at??null,R=Vt(A,"delete")?"1":"";return o`<tr class="clickable" data-id="${A.id}" data-target="${p}"><td>${wt(o`<a href="${p}">${A.subject||o`<em>untitled</em>`}</a>`,A)}</td><td>${$?ze("sending"):ze(A.status)}</td><td class="muted when${x?"":" empty"}">${x?W(x):"\u2014"}</td><td class="muted updated">${W(A.updated_at)}</td><td class="act"><button class="icon" data-menu="${A.id}" data-status="${$?"sending":A.status}" data-delete="${R}" data-target="${p}" aria-label="Post actions" aria-haspopup="menu">⋯</button></td></tr>`})}</tbody></table></div>`),tt(i,n,d);for(let A of I("tr[data-id]",i))A.onclick=T=>{let $=T.target;$ instanceof Element&&$.tagName!=="A"&&!$.closest("[data-menu]")&&A.dataset.target&&(location.hash=A.dataset.target)};for(let A of I("[data-menu]",i))A.onclick=T=>{T.stopPropagation();let $=A.dataset.status,p=A.dataset.target??"",x=[{label:$==="sending"?"Watch send":"Open",onClick:()=>{location.hash=p}}];if(A.dataset.delete&&A.dataset.menu){let R=A.dataset.menu;x.push({label:"Delete draft",danger:!0,onClick:()=>Xc(R,d,t)})}Wt(A,x,t)};return nt(a,n,E.page,d),E}catch(E){return!g&&w===l&&ne(i,q(E),()=>m?d():void f()),null}}let m=!1,h=g=>{m=!0,Nt({cursor:g.cursor,sends:[]},{update({sends:w,removed:E}){let M=new Set([...r.values()].flatMap(T=>T?.id??[]));(w.some(T=>r.has(T.post_id)&&Qc(r.get(T.post_id)??null,T))||E.some(T=>M.has(T)))&&u(!0)},stale:()=>Q(bo)},t)},f=async()=>{let g=await u(!1);g&&!m&&h(g)};qt(e,n,d),await f()}function Qc(e,t){return e?.id===t.id?t.status!==e.status||t.fire_at!==e.fire_at:t.status==="scheduled"||t.status==="sending"}function Xc(e,t,n){let s=ue(o`<h3>Delete draft?</h3><p class="hint">This permanently deletes the draft and its revisions. This can’t be undone.</p><div class="actions"><button type="button" id="dCancel">Cancel</button><button type="button" class="danger" id="dGo">Delete</button></div>`,n),i=c("#dGo",s.el);c("#dCancel",s.el).onclick=s.close,i.onclick=()=>G(i,"Deleting\u2026",async()=>{s.clearError();try{await H(`/api/posts/${e}`,{method:"DELETE"}),s.close(),O("Draft deleted"),t()}catch(a){s.error(q(a))}})}var go={pending:"Subscribed but hasn\u2019t clicked the confirmation email.",confirmed:"Receives your posts. They confirmed here, or opted in at the service you imported them from.",unsubscribed:"Left the list. They\u2019re sent nothing until they sign up again, or, unless they left by a complaint or an erasure, you add them back and they confirm."},Rr={reader:"They unsubscribed themselves, from the link or the button in your email.",publisher:"You unsubscribed them, when they asked you to.",complaint:"They reported your email as spam. Only their own sign-up brings them back, so you can\u2019t add them: ask them to subscribe through your subscribe page.",erased:"You erased them at their request. Only the address is kept, so it isn\u2019t mailed again, and only their own sign-up brings them back.",import:"The list you imported says they left, without saying why."},Cr="The address isn\u2019t sent your posts, whatever its status. The status stays as it was, greyed while the suppression keeps it from taking effect.",qe={bounce:"bounced",import_bounce:"bounced",manual:"blocked"},mn={bounced:"Mail to the address failed, here or at the service it was imported from. You can clear it.",blocked:"Stopped by hand, by you or at the service it was imported from. You can unblock it."},st={unsubscribe:{label:"Unsubscribe",when:"When they ask you to take them off the list. They can rejoin by subscribing again."},block:{label:"Block",when:"When you decide yourself to stop mailing an address. Their status stays as it was, and you can unblock it."},unblock:{label:"Unblock",when:"When you decide to mail an address you blocked again. They receive your posts only if they\u2019re confirmed."},clear:{label:"Clear",when:"When you know a bounce\u2019s cause is gone, such as a reader who tells you their mailbox is fixed. Mail goes again to an address that failed."},erase:{label:"Erase",when:"When they ask you to delete their data. They\u2019re unsubscribed and only their address is kept. It can\u2019t be undone, and they come back only by subscribing themselves."}};function hs(e,t){let n=ge(),s=ue(o`<h3>Add subscriber</h3><p class="hint">Starts the normal double opt-in: they get a confirmation email and won’t receive posts until they confirm.</p>${Ai()}${n?null:o`<label for="addEmail">Email address</label><input type="email" id="addEmail" placeholder="person@example.com">`}<div class="actions"><button type="button" id="aCancel">Cancel</button><button type="button" class="primary" id="aGo">Send confirmation</button></div>`,t),i=c("#aGo",s.el);if(c("#aCancel",s.el).onclick=s.close,i.disabled=n,n){c("#aCancel",s.el).focus();return}let a=c("#addEmail",s.el);a.focus(),i.onclick=()=>G(i,"Adding\u2026",async()=>{s.clearError();let r=gt(a.value);if(!vt(r)){s.error("Enter a valid email address",a);return}try{let l=await H("/api/subscribers",{method:"POST",json:{email:r}});s.close(),O(ed(r,l.action)),e()}catch(l){s.error(q(l),l instanceof ee&&l.field==="email"?a:void 0)}})}function ed(e,t){switch(t){case"already_confirmed":return`${e} is already confirmed`;case"suppressed":return`${e} is suppressed, so no confirmation was sent`;case"recently_sent":return`A confirmation went to ${e} a few minutes ago; try again later`;default:return`Confirmation sent to ${e}`}}function Hr(e,t,n){let s=ue(o`<h3>Unsubscribe this subscriber?</h3><p class="hint">Use this when <strong>${e.email}</strong> asks you to take them off the list. They stop receiving posts immediately, and can rejoin by subscribing again. To stop mailing them on your own judgment, block the address instead.</p><div class="actions"><button type="button" id="uCancel">Cancel</button><button type="button" class="danger" id="uGo">Unsubscribe</button></div>`,n),i=c("#uGo",s.el);c("#uCancel",s.el).onclick=s.close,i.onclick=()=>G(i,"Unsubscribing\u2026",async()=>{s.clearError();try{await H(`/api/subscribers/${e.id}/unsubscribe`,{method:"POST"}),s.close(),O(`Unsubscribed ${e.email}`),t()}catch(a){s.error(q(a))}})}function Ar(e,t,n){let s=ue(o`<h3>Block this address?</h3><p class="hint">Stops all mail to <strong>${e.email}</strong>, whatever their status, which stays as it is. Use this when you decide yourself to stop mailing them; if they asked to leave, unsubscribe them instead. You can unblock it later.</p><div class="actions"><button type="button" id="bCancel">Cancel</button><button type="button" class="danger" id="bGo">Block</button></div>`,n),i=c("#bGo",s.el);c("#bCancel",s.el).onclick=s.close,i.onclick=()=>G(i,"Blocking\u2026",async()=>{s.clearError();try{let a=await H("/api/suppressions",{method:"POST",json:{email:e.email}});s.close(),O(a.added?`Blocked ${e.email}`:`${e.email} was already suppressed (${qe[a.suppression.reason]})`),t()}catch(a){s.error(q(a))}})}function Pr(e,t,n){let s=e.status==="confirmed"?"They\u2019re confirmed, so they\u2019ll receive your posts again.":"They\u2019ll receive posts again only once they\u2019re confirmed.",i=ue(o`<h3>Unblock this address?</h3><p class="hint">Undoes the block on <strong>${e.email}</strong>. ${s}</p><div class="actions"><button type="button" id="ubCancel">Cancel</button><button type="button" class="primary" id="ubGo">Unblock</button></div>`,n),a=c("#ubGo",i.el);c("#ubCancel",i.el).onclick=i.close,a.onclick=()=>G(a,"Unblocking\u2026",async()=>{i.clearError();try{await Mr(e),i.close(),O(`Unblocked ${e.email}`),t()}catch(r){i.error(q(r))}})}function Mr(e){return H("/api/suppressions/lift",{method:"POST",json:{email:e.email,reason:e.suppression?.reason}})}function Ir(e,t,n){let s=e.status==="confirmed"?"They\u2019re confirmed, so they\u2019ll receive your posts again.":"They\u2019ll receive posts again only once they\u2019re confirmed.",i=ue(o`<h3>Clear this bounce?</h3><p class="hint">Lets mail go again to <strong>${e.email}</strong>, an address where mail failed. Clear it only when you know the reason is gone, such as a reader who tells you their mailbox is fixed. ${s}</p><div class="actions"><button type="button" id="cCancel">Cancel</button><button type="button" class="danger" id="cGo">Clear</button></div>`,n),a=c("#cGo",i.el);c("#cCancel",i.el).onclick=i.close,a.onclick=()=>G(a,"Clearing\u2026",async()=>{i.clearError();try{await Mr(e),i.close(),O(`Cleared the bounce on ${e.email}`),t()}catch(r){i.error(q(r))}})}function Nr(e,t,n){let s=ue(o`<h3>Erase this subscriber?</h3><p class="hint">Unsubscribes <strong>${e.email}</strong> and deletes how they joined and the evidence for it, and past sends show an erased recipient in its place. Only the address is kept, so it’s never mailed again. This can’t be undone. They can still rejoin by subscribing and confirming themselves, but you can’t add them back.</p><div class="actions"><button type="button" id="eCancel">Cancel</button><button type="button" class="danger" id="eGo">Erase</button></div>`,n),i=c("#eGo",s.el);c("#eCancel",s.el).onclick=s.close,i.onclick=()=>G(i,"Erasing\u2026",async()=>{s.clearError();try{let a=await H(`/api/subscribers/${e.id}/erase`,{method:"POST"});s.close(),O(nd(a)),t()}catch(a){s.error(q(a))}})}var td={bounce:"bounce",import_bounce:"bounce",manual:"block"};function nd({subscriber:e}){let t=e.suppression;return t?`Erased ${e.email}. Its ${td[t.reason]??"suppression"} still stands`:`Erased ${e.email}`}var sd=1500;function Re(e=!1,t="Copy"){return o`<span>${t}</span>${N(e?"check":"copyout")}`}function fn(e,t){return o`<button type="button" class="copy-btn" data-copy="${e}" aria-label="${t}">${Re()}</button>`}function Kt(e,t,n){return o`<div class="block code-block"><div class="block-head"><span class="block-label">${e}</span>${fn(n,`Copy ${e}`)}</div><pre><code>${t}</code></pre></div>`}var vo=new WeakMap;async function hn(e,t){try{await navigator.clipboard.writeText(t)}catch{O("Couldn\u2019t copy to clipboard");return}let n=vo.get(e),s=n?.label??e.getAttribute("aria-label")??"Copy",i=e.dataset.copyWord;window.clearTimeout(n?.timer),b(e,Re(!0,i)),e.setAttribute("aria-label","Copied"),e.classList.add("copied");let a=window.setTimeout(()=>{b(e,Re(!1,i)),e.setAttribute("aria-label",s),e.classList.remove("copied"),vo.delete(e)},sd);vo.set(e,{label:s,timer:a})}function ot(e){e.addEventListener("click",t=>{let n=t.target instanceof Element?t.target.closest("[data-copy]"):null;n&&e.contains(n)&&hn(n,n.dataset.copy??"")})}function od(e,t=!1){let n=[],s=e.filter(p=>Le(p,"missed"));s.length&&n.push({level:"red",text:`${s.length} scheduled send${s.length===1?"":"s"} passed the fire time without going out.`});let i=e.flatMap(p=>{let x=fe(p,"wedged");return x?[{s:p,count:x.count}]:[]}),[a]=i;if(a){let p=i.reduce((R,F)=>R+F.count,0),x=p===1?"delivery":"deliveries";n.push({level:"red",text:i.length===1?o`<a href="#/sent/${a.s.id}">${a.s.subject}</a> has ${p} ambiguous ${x} awaiting a decision; resolve ${p===1?"it":"them"} on its page.`:o`${p} ambiguous ${x} across <a href="#/sent">${i.length} sends</a> await a decision; resolve them on the Sent page.`})}let r=ge(),l=e.flatMap(p=>{let x=fe(p,"refused");return x?[{s:p,c:x}]:[]}),d=r?l.filter(p=>p.c.cause==="not_connected"):[],u=l.filter(p=>!d.includes(p)),m=p=>p.length===1&&p[0]?o`<a href="#/sent/${p[0].s.id}">${p[0].s.subject}</a>`:o`<a href="#/sent">${p.length} sends</a>`;d.length&&n.push({level:"red",text:o`<span class="dash-not-connected">${Zn(m(d),d.length)}</span>`});let[h]=u;h&&n.push({level:"red",text:o`${m(u)}: ${h.c.message}`});let f=e.filter(p=>Le(p,"stuck")&&!un(p)),[g]=f;g&&n.push({level:"amber",text:f.length===1?o`<a href="#/sent/${g.id}">${g.subject}</a>: ${fe(g,"stuck")?.message}`:o`<a href="#/sent">${f.length} sends</a> have handed off no one for a while, with no retry or provider problem to explain it.`});let w=e.flatMap(p=>{let x=fe(p,"provider_unavailable");return x?.severity==="warn"?[{s:p,c:x}]:[]}),[E]=w;E&&n.push({level:"amber",text:o`<a href="#/sent/${E.s.id}">${E.s.subject}</a>: ${E.c.message}`});let M=e.flatMap(p=>{let x=fe(p,"bounce_spike");return x?[{s:p,c:x}]:[]}),[A]=M;A&&n.push({level:"amber",text:o`<a href="#/sent/${A.s.id}">${A.s.subject}</a>: ${A.c.message}`});let T=e.flatMap(p=>{let x=fe(p,"no_receipts");return x?[{s:p,c:x}]:[]}),[$]=T;return $&&n.push({level:"amber",text:T.length===1?o`<a href="#/sent/${$.s.id}">${$.s.subject}</a>: ${$.c.message}`:o`<a href="#/sent">${T.length} sends</a> have had no delivery receipt long after they went out. The provider's events may not be reaching this app, so its bounces suppress no one and its complaints unsubscribe no one.`}),r&&!d.length&&!t&&n.push({level:"amber",text:o`<span class="dash-not-connected">No email provider connected, so nothing can be sent yet. ${He()} to start sending.</span>`}),n}function id(e){if(!e.length)return o``;let t=e.some(n=>n.level==="red")?"red":"amber";return o`<div class="health ${t}"><span class="health-dot">⚠️</span><div>${e.map(n=>o`<div>${n.text}</div>`)}</div></div>`}var Br=e=>e.filter(t=>t.status==="sending"&&!un(t));async function yo(e,t){let n=()=>{t.aborted||Q(yo)};b(e,o`<div class="dash" id="dash"><p class="muted">Loading…</p></div>`);let s=c("#dash",e),i,a,r,l;try{let[L,J,D]=await Promise.all([H("/api/posts?limit=200",{signal:t}),H("/api/sends?limit=200",{signal:t}),H("/api/subscribers",{signal:t})]);i=L.posts,a=J,r=D.counts,l=D.workers_plan_advice}catch(L){ne(s,q(L),n);return}let d=a.sends,u=new Map,m=to(X.appConfig),h=X.appConfig?.deployment??null,f=()=>d.some(L=>(L.status==="sending"||L.status==="sent")&&!L.email_only),g=()=>({named:!!X.appConfig?.settings.publication?.name?.trim(),onList:r.confirmed+r.pending,archived:f()});if(!i.length){b(s,o`<div class="dash-head"><div><h1>${m.name}</h1>${m.tagline?o`<p class="muted dash-tagline">${m.tagline}</p>`:null}<p class="muted">Let’s get your first post out the door.</p></div></div>${Gr(m,h,g(),"checklist")}<section class="dash-section"><h2>API access</h2>${Kr(!1)}</section>`),Vr(s,n,t);return}let w=()=>!f(),E=()=>w()?Gr(m,h,g(),"card"):o``,M=()=>id(od(Bt(d,u),w()&&ge())),A=[{label:"Confirmed",emph:!0,v:r.confirmed,filter:"confirmed"},{label:"Pending",v:r.pending,filter:"pending"},{label:"Unsubscribed",v:r.unsubscribed,filter:"unsubscribed"},{label:"Suppressed",v:r.suppressed,filter:"suppressed"}],T=o`<div class="tiles">${A.map(L=>o`<a class="tile${L.emph?" tile-emph":""}" href="#/subscribers/${L.filter}"><span class="tile-n">${L.v}</span><span class="tile-label">${L.label}${L.sub?o`<span class="tile-sub">${L.sub}</span>`:null}</span></a>`)}</div>`,$=i.filter(L=>L.status==="draft").slice(0,5),p=$.length?o`<div class="table-wrap"><table class="dash-drafts"><tbody>${$.map(L=>o`<tr class="clickable" data-id="${L.id}"><td>${wt(o`<a href="#/edit/${L.id}">${L.subject||o`<em>untitled</em>`}</a>`,L)}</td><td class="muted edited">edited ${W(L.updated_at)}</td></tr>`)}</tbody></table></div>`:o`<p class="muted">No drafts in progress.</p>`,x=h?.app_origin||location.origin,R=o`<div class="card pub-card">
|
|
61
|
+
<div class="pub-row"><code class="pub-val">${x}</code>${fn(x,"Copy the publication\u2019s address")}</div>
|
|
75
62
|
<div class="pub-foot"><a href="/" target="_blank" rel="noopener">View publication ↗</a></div>
|
|
76
|
-
</div>`,
|
|
63
|
+
</div>`,F=i.some(L=>L.author==="service"),V=Kr(F),Z=o`<div class="row quick-actions"><button data-act="new-post">New post</button><button data-act="add-sub">Add subscriber</button><button data-nav="#/settings">Edit publication</button></div>`;b(s,o`
|
|
77
64
|
<div class="dash-head">
|
|
78
|
-
<div><h1>${
|
|
65
|
+
<div><h1>${m.name}</h1>${m.tagline?o`<p class="muted dash-tagline">${m.tagline}</p>`:null}</div>
|
|
79
66
|
<button class="primary" data-act="new-post">New post</button>
|
|
80
67
|
</div>
|
|
81
|
-
<div id="dashHealth">${
|
|
82
|
-
<div id="dashSetup">${
|
|
83
|
-
<div id="dashActive">${
|
|
84
|
-
<section class="dash-section"><h2>Subscribers</h2>${
|
|
68
|
+
<div id="dashHealth">${M()}</div>
|
|
69
|
+
<div id="dashSetup">${E()}</div>
|
|
70
|
+
<div id="dashActive">${jr(Br(Bt(d,u)))}</div>
|
|
71
|
+
<section class="dash-section"><h2>Subscribers</h2>${T}${l?o`<p class="workers-plan" id="dashWorkersPlan" role="status">${l}</p>`:null}</section>
|
|
85
72
|
<div class="dash-cols">
|
|
86
|
-
<section class="dash-section"><h2>Scheduled</h2><div id="dashScheduled">${
|
|
87
|
-
<section class="dash-section"><h2>Drafts</h2>${
|
|
73
|
+
<section class="dash-section"><h2>Scheduled</h2><div id="dashScheduled">${Ur(Or(d))}</div></section>
|
|
74
|
+
<section class="dash-section"><h2>Drafts</h2>${p}</section>
|
|
88
75
|
</div>
|
|
89
|
-
<section class="dash-section"><h2>Sent</h2><div id="dashSent">${
|
|
90
|
-
<section class="dash-section"><h2>Quick actions</h2>${
|
|
76
|
+
<section class="dash-section"><h2>Sent</h2><div id="dashSent">${Dr(d)}</div></section>
|
|
77
|
+
<section class="dash-section"><h2>Quick actions</h2>${Z}</section>
|
|
91
78
|
<div class="dash-cols">
|
|
92
|
-
<section class="dash-section"><h2>Publication</h2>${
|
|
93
|
-
<section class="dash-section"><h2>API access</h2>${
|
|
94
|
-
</div>`),
|
|
79
|
+
<section class="dash-section"><h2>Publication</h2>${R}</section>
|
|
80
|
+
<section class="dash-section"><h2>API access</h2>${V}</section>
|
|
81
|
+
</div>`),Vr(s,n,t);for(let L of I("tr[data-id]",s))L.onclick=J=>{let D=J.target;D instanceof Element&&D.tagName!=="A"&&!D.closest("button")&&(location.hash=`#/edit/${L.dataset.id}`)};Fr(s),Wr(s),qr(s);let U=ps(s,t);U();let v=0,k=async()=>{let L=++v;try{let J=await H("/api/sends?limit=200",{signal:t});if(L!==v)return;d=J.sends,b(c("#dashScheduled",s),Ur(Or(d))),qr(s),U(),b(c("#dashSent",s),Dr(d)),Fr(s),b(c("#dashSetup",s),E()),Yr(c("#dashSetup",s),n,t),S()}catch{}},S=()=>{let L=Bt(d,u);b(c("#dashHealth",s),M()),b(c("#dashActive",s),jr(Br(L))),Wr(s),rd(s,[...u.values()])},C=L=>L==="scheduled"||L==="due";Nt(a,{update:L=>{for(let J of L.sends)u.set(J.id,J);for(let J of L.removed)u.delete(J);S(),(L.changes.length||L.removed.length||L.sends.some(J=>C(et(J))))&&k()},stale:n},t)}var Or=e=>e.filter(t=>t.status==="scheduled").sort((t,n)=>t.fire_at-n.fire_at);function Dr(e){let t=e.filter(n=>n.status==="sent"||n.status==="sending").slice(0,5);return t.length?o`<div class="table-wrap"><table class="dash-sent"><thead><tr><th>Subject</th><th>Status</th><th class="num">Recipients</th><th class="num">Delivered</th><th></th></tr></thead><tbody>${t.map(n=>{let s=n.email_only?n.links.unlisted:n.links.archive;return o`<tr class="clickable" data-send="${n.id}"><td>${wt(o`<a href="#/sent/${n.id}">${n.subject}</a>`,n)}</td><td>${ze(n.status)}</td><td class="num">${n.audience.count.toLocaleString()}</td><td class="num delivered">${Ot(n.counts)}</td><td class="act">${s?o`<a class="ghost-link" href="${s}" target="_blank" rel="noopener">${n.email_only?o`Web version ↗`:o`Archive ↗`}</a>`:null}</td></tr>`})}</tbody></table></div>`:o`<p class="muted">No sends yet.</p>`}function Fr(e){for(let t of I("tr[data-send]",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/sent/${t.dataset.send}`)}}function rd(e,t){for(let n of t){let s=I("#dashSent tr[data-send]",e).find(i=>i.dataset.send===n.id)?.querySelector("td.delivered");s&&b(s,Ot(n.counts))}}function Ur(e){if(!e.length)return o`<p class="muted">Nothing scheduled.</p>`;let t=Qn("card");return o`${e.map(n=>o`<div class="card spread clickable nextup sched-card" data-post="${n.post_id}"><div><a class="card-link sched-subj" href="#/edit/${n.post_id}">${n.subject}</a><div class="muted">${us(n)} · ${W(n.fire_at)} · ${n.audience.count} recipients${ds(n)}</div>${t}${Le(n,"remade")?o`<div class="sched-remade-mark">Look changed since your last test</div>`:null}</div></div>`)}`}function qr(e){for(let t of I("#dashScheduled .nextup",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/edit/${t.dataset.post}`)}}function jr(e){return e.length?o`<section class="dash-section"><h2>Active send${e.length===1?"":"s"}</h2>${e.map(t=>ms(t,{stuckNote:!1}))}</section>`:o``}function Wr(e){for(let t of I("#dashActive .active-card[data-watch]",e))t.onclick=n=>{let s=n.target;s instanceof Element&&s.tagName!=="A"&&(location.hash=`#/sent/${t.dataset.watch}`)}}function Vr(e,t,n){Yr(e,t,n),ot(e)}function Yr(e,t,n){for(let s of I("[data-nav]",e))s.onclick=()=>{location.hash=s.dataset.nav??""};for(let s of I("[data-act='new-post']",e))s.onclick=()=>ho(s);for(let s of I("[data-act='add-sub']",e))s.onclick=()=>hs(t,n)}function Kr(e){return e?o`<div class="card pub-card">
|
|
95
82
|
<p class="conn-status"><span class="conn-dot" aria-hidden="true"></span>Claude is connected.</p>
|
|
96
|
-
<div class="pub-foot pub-links"><a href="#/reference">${
|
|
83
|
+
<div class="pub-foot pub-links"><a href="#/api-reference">${N("code")}API reference →</a><a href="#/docs/connect-claude">${N("article")}Connection guide →</a></div>
|
|
97
84
|
</div>`:o`<div class="card pub-card">
|
|
98
85
|
<p class="pub-note">Let Claude draft, proofread, and schedule your posts.</p>
|
|
99
|
-
<div class="pub-foot pub-links"><a href="#/docs/connect-claude">${
|
|
100
|
-
</div>`}function
|
|
86
|
+
<div class="pub-foot pub-links"><a href="#/docs/connect-claude">${N("article")}Connect Claude →</a><a href="#/api-reference">${N("code")}API reference →</a></div>
|
|
87
|
+
</div>`}function Gr(e,t,n,s){let i=`${t?.app_origin||location.origin}/subscribe`,a=ge(),{onList:r}=n,l=d=>d?" setup-done":"";return o`<div class="card setup${s==="card"?" setup-card":""}">
|
|
101
88
|
<h2 class="setup-title">${s==="card"?"Finish setting up":"Set up your publication"}</h2>
|
|
102
89
|
<ol class="setup-steps">
|
|
103
90
|
<li class="setup-name${l(n.named)}"><div class="setup-step-main"><strong>Name your publication</strong><span class="muted">Currently “${e.name}”. Set the name, tagline, and brand in Settings.</span></div><button data-nav="#/settings">Settings</button></li>
|
|
104
|
-
${
|
|
105
|
-
${
|
|
91
|
+
${a?o`<li class="setup-connect"><div class="setup-step-main"><strong>Connect Amazon SES</strong><span class="muted">Nothing can be sent, and signups stay closed, until SES is connected.</span></div><button data-nav="${oo}">Connect Amazon SES</button></li>`:o`<li class="setup-check"><div class="setup-step-main"><strong>Check your setup</strong><span class="muted">Send yourself a test, and see how Kestrel tells you when something needs attention.</span></div><button data-nav="#/docs/check-your-setup">Docs</button></li>`}
|
|
92
|
+
${r?o`<li class="setup-done setup-list"><div class="setup-step-main"><strong>Bring your existing list</strong><span class="muted">${r.toLocaleString()} ${r===1?"subscriber":"subscribers"} on your list.</span></div><button data-nav="#/subscribers">Subscribers</button></li>`:o`<li class="setup-list"><div class="setup-step-main"><strong>Bring your existing list</strong><span class="muted">Import the subscribers you have on another service, with the consent they gave there. Skip this if you’re starting fresh.</span></div><button data-nav="#/subscribers/import">Import</button></li>`}
|
|
106
93
|
<li class="setup-write${l(n.archived)}"><div class="setup-step-main"><strong>Publish your first post</strong><span class="muted">Write a post, test it, and send it. It goes to your subscribers and onto your site.</span></div>${s==="card"?o`<button data-act="new-post">New post</button>`:o`<button class="primary" data-act="new-post">New post</button>`}</li>
|
|
107
|
-
${
|
|
94
|
+
${a?o`<li class="setup-subscribe"><div class="setup-step-main"><strong>Share your subscribe link</strong><span class="muted">Signups open once an email provider is connected; until then the page says they aren’t open yet.</span></div></li>`:o`<li class="setup-subscribe"><div class="setup-step-main"><strong>Share your subscribe link</strong><code class="setup-url">${i}</code></div><button type="button" data-copy="${i}" aria-label="Copy the subscribe link">${Re()}</button></li>`}
|
|
108
95
|
</ol>
|
|
109
|
-
</div>`}function
|
|
96
|
+
</div>`}var wo=Math.floor(5e3);var tb=`Workers Free suits a list of up to about ${1e3.toLocaleString("en-US")} subscribers. Past about ${wo.toLocaleString("en-US")}, one send can use up the day\u2019s database writes and stop this newsletter until midnight UTC.`,ad="Move to Workers Paid, or, if the account is on it already, set SUBREQUEST_BUDGET to 10000.";function zr(e){return`On the Workers Free plan\u2019s limits, a send to ${e.toLocaleString("en-US")} recipients may use up the day\u2019s database writes and stop this newsletter until midnight UTC, the send included. Workers Free suits a list of up to about ${1e3.toLocaleString("en-US")} subscribers. ${ad}`}function bs(e){return e.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,80).replace(/-+$/g,"")}function bn(e){return e.replace(/^https?:\/\//,"")}function Zr(){let e=Ii("");return e?bn(e):"/archive/"}function Ze(e,t){return{emailOnly:e.email_only,slug:e.slug,slugAuto:e.slug_auto,subject:e.subject,slugTaken:t,fixed:e.published_at!==null}}function So(e){return e.emailOnly?o`Sends by email only`:o`Sends by email and publishes at <code>${Zr()}${e.slug}</code>`}function $o(e){return N(e.emailOnly?"mail":"link")}var ld="Listed on your home page and in your public archive. Unchecked, it\u2019s sent as email only: anyone with the email can still open its web version.",Jr="Fixed once the post is sent, since people may have its link.";function Qr(e,t){return e.fixed?o`<p class="pub-note">${t}</p>`:e.slugAuto&&e.slugTaken?o`<p class="pub-taken">${N("info")}<span>Another post has <code>${e.slugTaken}</code>, so a number was added. You can change it above.</span></p>`:e.slugAuto?o`<p class="pub-note">Follows the subject until you type your own.</p>`:null}function cd(e,t,{include:n=!0,fixedNote:s=Jr}){let i=n?o`<label class="listing-check"><input type="checkbox" id="${t}Include"${e.emailOnly?null:o` checked`}><span><strong>Include in archive</strong><span class="listing-help">${ld}</span></span></label>`:null;return e.emailOnly?o`${i}`:o`${i}<div class="pub-address"><label class="pub-label" for="${t}Slug">Archive address</label><label class="url-field${e.fixed?" readonly":""}" id="${t}Field"><span class="url-base">${Zr()}</span><input type="text" id="${t}Slug" value="${e.slug}" spellcheck="false" autocomplete="off" autocapitalize="off"${e.fixed?o` readonly`:null}></label><div id="${t}Note">${Qr(e,s)}</div></div>`}function dd(e,t,n,s,{fixedNote:i=Jr,onChange:a}){let r=e.querySelector(`#${n}Include`);r&&(r.onchange=()=>{t.emailOnly=!r.checked,s(),a?.()});let l=e.querySelector(`#${n}Slug`),d=e.querySelector(`#${n}Note`);!l||!d||t.fixed||(l.onfocus=()=>l.select(),l.oninput=()=>{t.slug=l.value,t.slugAuto=!1,e.querySelector(`#${n}Field`)?.classList.remove("is-invalid"),b(d,Qr(t,i)??o``)},l.onblur=()=>{l.value.trim()===""&&(t.slugAuto=!0,t.slug=bs(t.subject)||t.slug,s())})}function gn(e,t){let n={};return t.emailOnly!==e.emailOnly&&(n.email_only=t.emailOnly),!e.fixed&&!t.emailOnly&&(t.slugAuto&&!e.slugAuto?n.slug_auto=!0:!t.slugAuto&&t.slug!==e.slug&&(n.slug=t.slug)),Object.keys(n).length?n:null}function $t(e,t,n,s={}){let i=()=>{b(e,cd(t,n,s)),dd(e,t,n,i,s)};i()}function vn(e,t,n){if(!(n instanceof ee)||n.field!=="slug"&&n.field!=="slug_auto")return!1;let s=e.querySelector(`#${t}Note`),i=e.querySelector(`#${t}Slug`);return!s||!i?!1:(e.querySelector(`#${t}Field`)?.classList.add("is-invalid"),b(s,o`<p class="pub-error" role="alert">${n.message}</p>`),i.focus(),!0)}var xt=q;function gs(e,t,n){let s=e.c_in_flight||0,a=ue(o`<h3>Resolve ${s} ambiguous ${s===1?"delivery":"deliveries"}</h3>
|
|
110
97
|
<p class="hint">A transport error left ${s} recipient${s===1?"":"s"} in flight: the request went out but the provider never confirmed, so there’s no way to know whether it was accepted. To avoid mailing anyone twice, the send won’t retry ${s===1?"it":"them"} on its own, so it can’t finish until you decide. Neither choice re-sends this post.</p>
|
|
111
98
|
<p class="hint">Kestrel can’t tell whether ${s===1?"this email":"these emails"} went out, so unless you know, <strong>Assume not sent</strong> is the safe default.</p>
|
|
112
99
|
<p class="hint"><strong>Assume not sent</strong>: recorded as unsent. ${s===1?"That reader":"Those readers"} may miss this post but ${s===1?"gets":"get"} your next one, and no one is mailed twice.</p>
|
|
113
100
|
<p class="hint"><strong>Assume sent</strong>: recorded as accepted by your provider, with delivery still waiting on its receipt. Choose this when you know otherwise, such as a reader who says they got it.</p>
|
|
114
|
-
<div class="actions"><button type="button" id="rCancel">Cancel</button><button type="button" id="rUnsent">Assume not sent</button><button type="button" class="primary" id="rAccepted">Assume sent</button></div>`,n);c("#rCancel",
|
|
101
|
+
<div class="actions"><button type="button" id="rCancel">Cancel</button><button type="button" id="rUnsent">Assume not sent</button><button type="button" class="primary" id="rAccepted">Assume sent</button></div>`,n);c("#rCancel",a.el).onclick=a.close;let r=(u,m,h)=>G(u,"Resolving\u2026",async()=>{a.clearError();try{let f=await H(`/api/sends/${e.id}/resolve`,{method:"POST",json:{resolution:m}});a.close(),O(f.completed?"Send completed":`Marked ${h}`),t()}catch(f){a.error(xt(f))}}),l=c("#rUnsent",a.el),d=c("#rAccepted",a.el);l.onclick=()=>r(l,"unsent","not sent"),d.onclick=()=>r(d,"accepted","sent")}async function vs(e,t,n,s){let i=s;if(!i)try{let w=await H(`/api/posts/${e.post_id}`,{signal:n});i=Ze(w.post,w.subject_slug_held)}catch(w){n.aborted||O(xt(w));return}let a=i,r={...a},l=yt(zn()),d=new Date(yt(new Date(e.fire_at))),u=ue(o`<h3 id="rsHead">Edit schedule</h3>
|
|
115
102
|
<p class="hint" id="rsHint">It sends at the time you pick, with a cancelable window until then.</p>
|
|
116
|
-
${
|
|
117
|
-
<label for="rsWhen">Send at</label><input type="datetime-local" id="rsWhen" min="${l}" value="${
|
|
103
|
+
${Mi()}
|
|
104
|
+
<label for="rsWhen">Send at</label><input type="datetime-local" id="rsWhen" min="${l}" value="${yt(d)}">
|
|
118
105
|
<div class="dialog-settings" id="rsForm"></div>
|
|
119
|
-
<div class="actions"><button type="button" id="rsCancel">Cancel</button><button type="button" class="primary" id="rsGo">Save</button></div>`,n),m=c(".modal",u.el);m.setAttribute("aria-labelledby","rsHead")
|
|
120
|
-
`).map(n=>{let s,i=!1,
|
|
121
|
-
<div class="web-bar" role="note"><div class="web-bar-in">${
|
|
122
|
-
<iframe class="web-frame" sandbox="${
|
|
123
|
-
</div>`);let l=t.querySelector(".web-frame");if(!l)return;l.onload=()=>{let d=l.contentDocument;if(!d||n.aborted)return;
|
|
106
|
+
<div class="actions"><button type="button" id="rsCancel">Cancel</button><button type="button" class="primary" id="rsGo">Save</button></div>`,n),m=c(".modal",u.el);m.setAttribute("aria-labelledby","rsHead"),$t(c("#rsForm",m),r,"rs");let h=c("#rsGo",m),f=c("#rsWhen",m),g=!1;c("#rsCancel",m).onclick=()=>{u.close(),g&&t()},h.onclick=()=>G(h,"Saving\u2026",async()=>{u.clearError();let w=f.value,E=w?new Date(w).getTime():Number.NaN;if(Number.isNaN(E)){u.error("Pick a valid date & time",f);return}let M=E===d.getTime();if(!M&&Yn(E)){u.error(Gn(),f);return}let A=gn(a,r);if(M&&!A){u.close();return}let T=$=>$ instanceof ee&&$.status===404?(u.close(),O("This post was deleted elsewhere."),Ue(),!0):!1;if(A)try{await H(`/api/posts/${e.post_id}/settings`,{method:"PUT",json:A})}catch($){!T($)&&!vn(m,"rs",$)&&u.error(xt($));return}if(!M)try{await H(`/api/sends/${e.id}/reschedule`,{method:"POST",json:{fire_at:new Date(E).toISOString()}})}catch($){if(T($))return;let p=$ instanceof ee&&$.field==="fire_at";if(A){if(a={...r},g=!0,p){u.error(`Settings saved. ${xt($)}`,f);return}u.close(),O(`Settings saved. The send time didn\u2019t change: ${xt($)}`),t();return}p?u.error(xt($),f):u.error(xt($));return}u.close(),O(M?"Saved":"Rescheduled"),t()}),f.focus()}function Xr(e,t={}){return["default-src 'none'","script-src 'none'","object-src 'none'","frame-src 'none'","base-uri 'none'",`form-action ${e}`,t.framed?null:"frame-ancestors 'none'","img-src * data:","style-src 'unsafe-inline' https://fonts.googleapis.com","font-src https://fonts.gstatic.com"].filter(n=>n!==null).join("; ")}var ea=e=>e.preventDefault(),Gt="allow-same-origin allow-popups allow-popups-to-escape-sandbox";function Yt(e){for(let t of e.querySelectorAll("a[href], area[href]")){if(t.getAttribute("href")?.startsWith("#")){t.addEventListener("click",ea),t.addEventListener("auxclick",ea);continue}t.target="_blank",t.rel="noopener noreferrer"}}function ud(e,t){let n=/<head(\s[^>]*)?>/i;return n.test(e)?e.replace(n,s=>s+t):t+e}function ys(e){return ud(e,`<meta http-equiv="Content-Security-Policy" content="${Xr("'none'",{framed:!0})}">`)}function ta(e){return e.replace(/\{\{\s*(?:if\s+)?[\w.]+\s*\}\}/g,t=>`<span class="cx-var">${t}</span>`)}function pd(e){let t=ae(e);return t=t.replace(/"[^&]*?"/g,n=>`<span class="cx-str">${n}</span>`),t=t.replace(/(<\/?)([a-zA-Z][\w-]*)/g,(n,s,i)=>`<span class="cx-punct">${s}</span><span class="cx-tag">${i}</span>`),t=t.replace(/(\/?)>/g,(n,s)=>`<span class="cx-punct">${s}></span>`),ta(t)}function md(e){let t=ae(e);return t=t.replace(/"[^&]*?"/g,n=>`<span class="cx-str">${n}</span>`),t=t.replace(/#[0-9a-fA-F]{3,8}\b/g,n=>`<span class="cx-num">${n}</span>`),t=t.replace(/@[\w-]+/g,n=>`<span class="cx-at">${n}</span>`),t=t.replace(/^(\s*)(?![@\s])([^\n{}<]+?)(\s*)\{$/gm,(n,s,i,a)=>`${s}<span class="cx-sel">${i}</span>${a}{`),t=t.replace(/^(\s*)([a-z-]+)(\s*:)/gm,(n,s,i,a)=>`${s}<span class="cx-prop">${i}</span>${a}`),ta(t)}function na(e){return j(String(e).split(/(<style>[\s\S]*?<\/style>)/).map(t=>{let n=t.match(/^<style>([\s\S]*?)<\/style>$/);return n?`<span class="cx-punct"><</span><span class="cx-tag">style</span><span class="cx-punct">></span>${md(n[1]??"")}<span class="cx-punct"></</span><span class="cx-tag">style</span><span class="cx-punct">></span>`:pd(t)}).join(""))}function xo(e){return ae(e).replace(/(`[^`\n]+`)|(!?)(\[[^\]\n]*\])\(([^)\n]*)\)|(\*\*|__)([^\n]+?)\5|\*([^*\n]+?)\*|\b_([^_\n]+?)_\b/g,(t,n,s,i,a,r,l,d,u)=>{if(n)return`<span class="cx-md-code">${n}</span>`;if(i!==void 0)return`${s||""}${i}(<span class="cx-md-url">${a}</span>)`;if(r)return`<span class="cx-md-mark">${r}</span><span class="cx-md-strong">${l}</span><span class="cx-md-mark">${r}</span>`;let[m,h]=d!==void 0?["*",d]:["_",u];return`<span class="cx-md-mark">${m}</span><span class="cx-md-em">${h}</span><span class="cx-md-mark">${m}</span>`})}function fd(e){let t=e.match(/^(\s{0,3}#{1,6})(\s.*)?$/);if(t)return`<span class="cx-md-hmark">${ae(t[1]??"")}</span><span class="cx-md-htext">${ae(t[2]||"")}</span>`;if(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(e))return`<span class="cx-md-bmark">${ae(e)}</span>`;let n=e.match(/^(\s{0,3})((?:>\s?)+)(.*)$/);if(n)return`${ae(n[1]??"")}<span class="cx-md-bmark">${ae(n[2]??"")}</span>${xo(n[3]??"")}`;let s=e.match(/^(\s*)([-*+]|\d{1,9}[.)])(\s+)(.*)$/);return s?`${s[1]}<span class="cx-md-bmark">${ae(s[2]??"")}</span>${s[3]}${xo(s[4]??"")}`:xo(e)}function sa(e){let t=!1;return j(String(e).split(`
|
|
107
|
+
`).map(n=>{let s,i=!1,a=n.match(/^(\s*)(```+|~~~+)(.*)$/);if(a){t=!t;let r=a[3]?`<span class="cx-md-bmark">${ae(a[3])}</span>`:"";s=`${a[1]}${ae(a[2]??"")}${r}`}else t?(i=!0,s=ae(n)):s=fd(n);return`<span class="cx-md-ln${i?" cx-md-band":""}">${s}</span>`}).join(""))}var hd=/("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],])/g;function oa(e){let t=JSON.stringify(e,null,2)??"",n="",s=0;for(let i of t.matchAll(hd)){n+=ae(t.slice(s,i.index));let[a,r,l,d,u]=i;r!==void 0?n+=l?`<span class="cx-prop">${ae(r)}</span><span class="cx-punct">${ae(l)}</span>`:`<span class="cx-str">${ae(r)}</span>`:d!==void 0?n+=`<span class="cx-num">${d}</span>`:u!==void 0?n+=`<span class="cx-tag">${u}</span>`:n+=`<span class="cx-punct">${ae(a)}</span>`,s=i.index+a.length}return j(n+ae(t.slice(s)))}function ia(e){return j(ae(e).replace(/^curl\b/,'<span class="cx-tag">curl</span>').replace(/(^|\s)(-[A-Za-z])\b/g,'$1<span class="cx-num">$2</span>').replace(/\$[A-Z_]+/g,'<span class="cx-var">$&</span>'))}var bd={idleMs:5e3,maxMs:3e4};function ra(e,t=bd){let n=null,s=null,i=()=>{n!==null&&clearTimeout(n),s!==null&&clearTimeout(s),n=null,s=null},a=()=>{i(),e()};return{touch(){n!==null&&clearTimeout(n),n=setTimeout(a,t.idleMs),s===null&&(s=setTimeout(a,t.maxMs))},cancel:i,get pending(){return n!==null||s!==null}}}var ws=class{constructor(t){this.read=t;this.#e=JSON.stringify(t())}read;#e;snapshot(){return JSON.stringify(this.read())}get dirty(){return this.snapshot()!==this.#e}markSaved(t=this.snapshot()){this.#e=t}};var aa=Promise.resolve();function ko(e){aa=e}function la(){return aa.then(()=>{},()=>{})}function ca(e){if(!(e instanceof ee)||e.status!==409)return null;let t=e.data;return t?.error==="stale_revision"&&typeof t.current_revision=="string"?{kind:"stale",revision:t.current_revision,author:typeof t.author=="string"?t.author:null}:{kind:"locked"}}var Ss=class{base;warnedRevision=null;constructor(t){this.base=t}saved(t){this.base=t}noteWarned(t){t.kind==="stale"&&(this.warnedRevision=t.revision)}adoptWarned(){this.warnedRevision!==null&&(this.base=this.warnedRevision),this.warnedRevision=null}clearWarning(){this.warnedRevision=null}decide(t,n){if(n.saving||this.base!==n.baseAtRequest)return"ignore";if(t.status!=="draft")return{kind:"locked"};let s=t.revision;return s&&s!==this.base&&s!==this.warnedRevision?{kind:"stale",revision:s,author:t.author}:"fresh"}};function ua(e){return`#/web/post/${encodeURIComponent(e)}`}function pa(e,t){if(e!=="post"||!t)return null;try{return decodeURIComponent(t)}catch{return null}}function gd(e){let t=o`<span class="web-bar-tag">Web version</span>`;return e?o`${t} <span class="web-bar-text">Not public yet</span>`:t}function da(e){try{return new URL(e).href}catch{return null}}function ma(e,t,n){let s=t?da(t):null;if(s)for(let i of e.querySelectorAll("a[href], area[href]")){let a=i.getAttribute("href");a&&da(a)===s&&i.setAttribute("href",n)}}function vd(e){location.replace(e)}async function yd(e,t){try{return await H(`/api/posts/${encodeURIComponent(e)}`,{signal:t})}catch{return null}}async function Eo(e,t,n){let s=document.title;document.title="Web version",ft(n,()=>{document.title=s});try{await la();let[i,a]=await Promise.all([At(`/api/posts/${encodeURIComponent(e)}/web`,{signal:n}),yd(e,n)]);if(n.aborted)return;if(a&&a.post.published_at!==null){vd(a.post.unlisted_url);return}let r=gd(a!==null);b(t,o`<div class="web-page">
|
|
108
|
+
<div class="web-bar" role="note"><div class="web-bar-in">${r}</div></div>
|
|
109
|
+
<iframe class="web-frame" sandbox="${Gt}" title="Web version" scrolling="no"></iframe>
|
|
110
|
+
</div>`);let l=t.querySelector(".web-frame");if(!l)return;l.onload=()=>{let d=l.contentDocument;if(!d||n.aborted)return;Yt(d),d.title&&(document.title=`Web version \xB7 ${d.title}`);let u=()=>{let m=d.body?.scrollHeight;m&&(l.style.height=`${m}px`)};if(u(),d.body&&typeof ResizeObserver<"u"){let m=new ResizeObserver(u);m.observe(d.body),ft(n,()=>m.disconnect())}},l.srcdoc=ys(i)}catch(i){if(n.aborted)return;b(t,o`<div class="web-error"></div>`);let a=t.querySelector(".web-error");if(a){let l=i instanceof ee&&i.status===404?"There\u2019s no post here. It may have been deleted.":q(i);ne(a,l,()=>Q((d,u)=>Eo(e,d,u)))}}}var Je=q,wd=[[["heading","Heading"],["bold","Bold (\u2318B)"],["italic","Italic (\u2318I)"]],[["quote","Quote"],["code","Code"],["link","Link (\u2318K)"]],[["ul","Bulleted list"],["ol","Numbered list"],["indent","Indent"]]],Sd=o` readonly`,$d=o` hidden`,xd=o` aria-disabled="true"`;async function _o(e,t,n){let s=()=>{n.aborted||Q((y,P)=>_o(e,y,P))},i=!1,a=(y="This post was deleted elsewhere.")=>{i||(i=!0,O(y),n.aborted||$s("#/drafts"))};b(t,o`<p class="muted">Loading…</p>`);let r,l=null;try{if(r=await H(`/api/posts/${e}`,{signal:n}),n.aborted)return;let y=Ed(r);if(y){$s(y);return}}catch(y){if(Ie(y)){a("There\u2019s no post here. It may have been deleted.");return}ne(t,Je(y),s);return}let d=pn(r.post);if(d){try{l=await H(`/api/sends/${d.id}`,{signal:n})}catch(y){if(n.aborted)return;if(Ie(y)){a();return}}if(n.aborted||l&&fa(l.send,s))return}let{post:u,markdown:m}=r,h=Ze(u,r.subject_slug_held),f=!Vt(u,"edit"),g=Vt(u,"schedule")!==void 0,w=new Ss(u.current_revision),E=wd.map((y,P)=>o`${P?o`<span class="sep"></span>`:null}${y.map(([_,B])=>o`<button type="button" class="tb" data-fmt="${_}" title="${B}" aria-label="${B}"${f?xd:null}>${N(_)}</button>`)}`),M=f?Sd:null,A=`${location.origin}${location.pathname}${ua(e)}`;b(t,o`
|
|
124
111
|
<div class="editor-head">
|
|
125
112
|
<a href="#/drafts" class="back">← Drafts</a>
|
|
126
113
|
<div class="editor-head-right">
|
|
127
|
-
<span class="email-only-mark" id="headEmailOnly"${
|
|
128
|
-
<a class="ghost btn" id="webLink" href="${
|
|
129
|
-
<button type="button" class="ghost" id="settingsBtn">${
|
|
114
|
+
<span class="email-only-mark" id="headEmailOnly"${u.email_only&&!f?null:$d}>${N("mail")}Email only</span>
|
|
115
|
+
<a class="ghost btn" id="webLink" href="${A}" target="_blank" rel="noopener" aria-label="Web version (opens in a new tab)">Web version ↗</a>
|
|
116
|
+
<button type="button" class="ghost" id="settingsBtn">${N("settings")}<span>Post settings</span></button>
|
|
130
117
|
</div>
|
|
131
118
|
</div>
|
|
132
|
-
${
|
|
119
|
+
${f&&d?o`<div class="banner banner-scheduled banner-split"><div class="banner-top"><div class="sched-main"><span id="schedWhen"></span>${Qn("banner")}<div class="sched-remade" id="schedRemade" hidden><span id="schedRemadeMsg"></span></div><div class="sched-remade" id="schedUntested" hidden><span id="schedUntestedMsg"></span></div><div class="sched-note" id="schedDailyWrites" hidden><span class="sched-note-mark" aria-hidden="true">⚠️</span><span class="sched-note-msg" id="schedDailyWritesMsg"></span></div></div><span class="row" id="schedControls"><button type="button" class="ghost" id="rescheduleSchedule">Edit schedule</button><button type="button" class="ghost" id="cancelSchedule">Cancel</button></span></div><div class="banner-bottom sched-dest"><span class="sched-dest-icon" id="schedDestIcon">${$o(h)}</span><span id="schedDest">${So(h)}</span></div></div>`:null}
|
|
133
120
|
<div id="freshnessBanner" class="banner banner-conflict" role="alert" hidden></div>
|
|
134
121
|
<div class="editor-form">
|
|
135
122
|
<label for="f-subject">Subject</label>
|
|
136
|
-
<input id="f-subject" value="${
|
|
123
|
+
<input id="f-subject" value="${u.subject}" placeholder="Untitled"${M}>
|
|
137
124
|
|
|
138
125
|
<label for="f-markdown">Body</label>
|
|
139
|
-
<div class="composer${
|
|
126
|
+
<div class="composer${f?" composer-locked":""}">
|
|
140
127
|
<div class="composer-head">
|
|
141
128
|
<div class="ctabs" role="tablist">
|
|
142
|
-
<button type="button" class="ctab active" data-tab="edit" data-text="Edit" role="tab" aria-selected="true"><span class="ctab-label">${
|
|
143
|
-
<button type="button" class="ctab" data-tab="preview" data-text="Preview" role="tab" aria-selected="false"><span class="ctab-label">${
|
|
129
|
+
<button type="button" class="ctab active" data-tab="edit" data-text="Edit" role="tab" aria-selected="true"><span class="ctab-label">${N("editable")}Edit</span></button>
|
|
130
|
+
<button type="button" class="ctab" data-tab="preview" data-text="Preview" role="tab" aria-selected="false"><span class="ctab-label">${N("preview")}Preview</span></button>
|
|
144
131
|
</div>
|
|
145
|
-
<div class="toolbar" role="toolbar" aria-label="Formatting">${
|
|
132
|
+
<div class="toolbar" role="toolbar" aria-label="Formatting">${E}</div>
|
|
146
133
|
</div>
|
|
147
|
-
<div class="composer-body${
|
|
134
|
+
<div class="composer-body${f?" locked":""}" id="composerBody">
|
|
148
135
|
<pre class="md-hl" id="mdHl" aria-hidden="true"><code></code></pre>
|
|
149
|
-
<textarea id="f-markdown" class="editor"${
|
|
150
|
-
<iframe id="previewFrame" class="preview" sandbox="${
|
|
136
|
+
<textarea id="f-markdown" class="editor"${f?null:o` placeholder="Type your post in Markdown…"`}${M}>${m}</textarea>
|
|
137
|
+
<iframe id="previewFrame" class="preview" sandbox="${Gt}" title="Email preview" hidden></iframe>
|
|
151
138
|
</div>
|
|
152
|
-
${
|
|
139
|
+
${f?o`<div class="composer-foot composer-foot-lock" id="lockFoot" role="status">${N("readonly")}<span>Cancel the schedule to edit</span></div>`:o`<div class="composer-foot" id="dropFoot">${N("paperclip")}<span>Paste, drop, or click to add images</span></div>`}
|
|
153
140
|
<input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
|
|
154
141
|
</div>
|
|
155
142
|
<div id="warnings"></div>
|
|
156
|
-
${
|
|
143
|
+
${f?null:Jn("editor-not-connected",o`No email provider connected: you can write and preview, not send. ${He()}.`)}
|
|
157
144
|
|
|
158
145
|
<div class="actions-bar">
|
|
159
|
-
${
|
|
146
|
+
${f?o`<div class="row"><button type="button" class="secondary" id="testBtn">${N("send")}<span>Send test email</span></button></div>`:o`<div class="row">
|
|
160
147
|
<button type="button" class="ghost" id="saveBtn">Save draft</button>
|
|
161
148
|
<span class="save-status" id="saveStatus" aria-live="polite"></span>
|
|
162
149
|
</div>
|
|
163
150
|
<div class="row">
|
|
164
|
-
<button type="button" class="secondary" id="testBtn">${
|
|
151
|
+
<button type="button" class="secondary" id="testBtn">${N("send")}<span>Send test email</span></button>
|
|
165
152
|
<button type="button" class="primary" id="scheduleBtn">Schedule</button>
|
|
166
153
|
</div>`}
|
|
167
154
|
</div>
|
|
168
|
-
</div>`);let
|
|
169
|
-
`,
|
|
170
|
-
`).map(
|
|
171
|
-
`)
|
|
172
|
-
`)
|
|
173
|
-
![${
|
|
174
|
-
|
|
175
|
-
`),
|
|
176
|
-
`),E.error(Gn(Y,ce.length,Se));return}E.close(),N(Tt(`Test sent to ${Y} address${Y===1?"":"es"}`))}catch(Y){if(Ie(Y)){E.close(),St(Y);return}E.error(Ye(Y))}})};let Wo=v?null:c("#scheduleBtn");return Wo&&(Wo.onclick=()=>{if(!$())return;let b=ut(In()),A=ut(new Date(Date.now()+24*3600*1e3)),E=fe(),P={...y,subject:O.value};P.slugAuto&&!P.slugTaken&&(P.slug=os(P.subject)||P.slug);let ee="It sends at the time you pick, with a cancelable window until then.",ce=()=>o`<h3 id="schHead">Schedule this post</h3><p class="hint" id="schHint">${ee}</p>${ci()}${Us()}${E?null:o`<label for="schWhen">Send at</label><input type="datetime-local" id="schWhen" min="${b}" value="${A}"><div class="dialog-settings" id="schForm"></div>`}<div class="actions"><button type="button" id="schCancel">Cancel</button><button type="button" class="primary" id="schGo">Schedule</button></div>${E?null:o`<div class="altrow"><span class="altrow-note">Or send as soon as possible.</span><button type="button" class="linkbtn" id="toSendNow">Send now →</button></div>`}`,Ee=A,ne=de(ce(),n),Y=c(".modal",ne.el),Se=null,st=()=>(Se??=R("/api/subscribers?status=confirmed&suppressed=hide&limit=1",{signal:n}).then(pe=>pe.page.total).catch(()=>null),Se),Oe=async()=>{if(!Y.querySelector("#schDailyWrites"))return;let pe=await st(),Re=Y.querySelector("#schDailyWrites");Re&&pe!==null&&pe>Cn&&(Re.textContent=ii(pe),Re.hidden=!1)},ge=()=>{ne.close(),qe.scrollIntoView({block:"nearest"})},Wt=()=>ft(c("#schForm",Y),P,"sch"),me=()=>({email_only:P.emailOnly,...an(y,P)}),$e=pe=>{ln(Y,"sch",pe)||ne.error(Ye(pe))},De=()=>z(c("#schGo",Y),"Scheduling\u2026",async()=>{ne.clearError();let pe=c("#schWhen",Y),Re=pe.value?new Date(pe.value).getTime():Number.NaN;if(Number.isNaN(Re)){ne.error("Pick a valid date & time",pe);return}if(Mn(Re)){ne.error(Pn(),pe);return}try{if(await Ne(!0)==="refused"){ge();return}await R(`/api/posts/${e}/schedule`,{method:"POST",json:{fire_at:new Date(Re).toISOString(),...me()}}),ne.close(),N(Tt(`Scheduled for ${K(Re)}. Send yourself a test.`)),s()}catch(We){if(Ie(We)){ne.close(),St(We);return}if(We instanceof J&&We.field==="fire_at"){ne.error(Ye(We),c("#schWhen",Y));return}$e(We)}}),$t=()=>z(c("#snGo",Y),"Queuing\u2026",async()=>{ne.clearError();try{if(await Ne(!0)==="refused"){ge();return}let{send:pe}=await R(`/api/posts/${e}/send`,{method:"POST",json:me()});ne.close(),N(Tt(`Sends at ${K(pe.fire_at)}, cancelable until then.`)),s()}catch(pe){if(Ie(pe)){ne.close(),St(pe);return}$e(pe)}});function Vo(){if(Y.setAttribute("aria-labelledby","schHead"),c("#schCancel",Y).onclick=ne.close,c("#schGo",Y).onclick=De,c("#schGo",Y).disabled=E,E){c("#schCancel",Y).focus();return}c("#toSendNow",Y).onclick=ll,c("#schWhen",Y).value=Ee,Wt(),c("#schWhen",Y).focus(),Oe()}async function ll(){let pe=Y.querySelector("#schWhen");pe&&(Ee=pe.value),h(Y,o`<h3 id="snHead">Send now?</h3><p class="hint">Sends this draft, as it is now, to <strong id="snWho">your subscribers</strong> in ${ai()}. The post locks until then, and you can cancel any time before it starts sending.</p><div class="dialog-settings" id="schForm"></div>${Us()}<div class="actions"><button type="button" class="linkbtn actions-back" id="toSchedule">← Back to schedule</button><button type="button" id="snCancel">Cancel</button><button type="button" class="primary" id="snGo">Send now</button></div>`),Y.setAttribute("aria-labelledby","snHead"),c("#snCancel",Y).onclick=ne.close,c("#snGo",Y).onclick=$t,Wt(),c("#snGo",Y).disabled=fe(),c("#toSchedule",Y).onclick=()=>{h(Y,ce()),Vo()},c("#snGo",Y).focus(),Oe();let Re=await st(),We=Y.querySelector("#snWho");We&&Re!==null&&(We.textContent=`${Re} recipient${Re===1?"":"s"}`)}Vo()}),ol}function jr(e,t){return e.status==="sending"||e.status==="sent"?(ds(`#/sent/${e.id}`),!0):e.status==="canceled"?(t(),!0):!1}function Xc(e,t){return Te(e,"missed")?o`Scheduled for <strong>${K(e.fire_at)}</strong> · <span class="countdown countdown-missed">${Yn(e.fire_at)}</span>`:t?o`Scheduled for <strong>${K(e.fire_at)}</strong> · <span class="countdown">${Qt(e.fire_at,!0)}</span>`:o`Scheduled for <strong>${K(e.fire_at)}</strong> · <span class="countdown">${Qt(e.fire_at)}</span>`}function Ie(e){return e instanceof J&&e.status===404}function ed(e){return e.sending?`#/sent/${e.sending.id}`:e.post.status==="sent"?e.sent?`#/sent/${e.sent.id}`:"#/sent":null}function ds(e){location.replace(e)}function td(e,t){document.addEventListener("visibilitychange",()=>{document.hidden||e()},{signal:t})}function nd(e){return e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,"").replace(/\s/g,"-")}function Wr(e){let t=new Map;return e.map(n=>{let s=nd(n),i=t.get(s)??0;return t.set(s,i+1),i?`${s}-${i}`:s})}var ao=null;function sd(e){let t=document.createElement("div");h(t,q(e.html||""));let n=(t.querySelector("p")?.textContent||"").trim().replace(/\s+/g," "),s=/[:;,]$/.test(n),i=n.replace(/[\s:;,]+$/,""),r=150;return i.length>r?`${i.slice(0,r).replace(/[\s.,;:]+\S*$/,"")}\u2026`:s?`${i}\u2026`:i}async function lo(e,t,n,s){if(h(t,_t("docs",null,o`<div class="docs-index"><p class="muted">Loading…</p></div>`)),!ao)try{ao=await R("/api/docs",{signal:n})}catch(d){n.aborted||ie(c(".room-main",t),W(d),()=>X((u,m)=>lo(e,u,m,s)));return}let{sets:i,docs:r}=ao,a=i[0];if(!r.length||!a){h(c(".room-main"),o`<p class="muted">No documentation.</p>`);return}let l=e?i.find(d=>d.id===e):a;l?Gr(t,l,i,r):e&&rd(t,i,r,e,s?od(s):void 0)}function od(e){try{return decodeURIComponent(e)}catch{return}}function Gr(e,t,n,s){let i=(y,v)=>{let k=sd(y);return o`<li><a class="doc-card" href="#/docs/${y.slug}">${v===null?null:o`<span class="doc-card-n">${String(v).padStart(2,"0")}</span>`}<span class="doc-card-main"><span class="doc-card-t">${y.title} <span class="doc-card-go" aria-hidden="true">→</span></span>${k?o`<span class="doc-card-d">${k}</span>`:null}</span></a></li>`},r=t.sections.map(y=>{let v=s.filter(L=>L.set===t.id&&L.section===y.id);if(!v.length)return null;let k=v.map((L,I)=>i(L,y.numbered?I+1:null)),C=y.blurb?o`<div class="doc-section-blurb">${q(y.blurb)}</div>`:null;return o`<section class="doc-section"><h2 class="doc-section-t">${y.title}</h2>${C}${y.numbered?o`<ol class="doc-cards">${k}</ol>`:o`<ul class="doc-cards">${k}</ul>`}</section>`}),a=o`<nav class="docs-tabs" aria-label="Doc sets">${n.map(y=>o`<a href="#/docs/${y.id}" title="${y.title}"${y.id===t.id?o` aria-current="page"`:null}>${y.name}</a>`)}</nav>`,l=o`<div class="docs-index"><h1>Kestrel docs</h1><p class="docs-index-intro"><strong>Publishing</strong> covers writing and sending your newsletter, from naming your publication to what happens after each send. <strong>Self-hosting</strong> covers deploying Kestrel to your own Cloudflare account and keeping it running. One person often does both.</p>${a}${r}</div>`,d=Q.appConfig?.deployment.build.repoUrl||"",u=(y,v,k=v)=>o`<a class="rail-link" href="${y}" target="_blank" rel="noopener" aria-label="${v}"><span class="rail-link-full">${v}</span><span class="rail-link-short" aria-hidden="true">${k}</span> <span aria-hidden="true">↗</span></a>`,m=o`<div class="toc-label">Kestrel</div>${u("https://getkestrel.dev","Project site","Project")}${d?[u(d,"Source on GitHub","Source"),u(`${d}/blob/HEAD/LICENSE`,"License")]:null}`;h(e,_t("docs",m,l)),window.scrollTo(0,0)}var co=matchMedia("(max-width: 720px)");function Yr(e){if(!e)return;let t=co.matches;e.open=!t,c("summary",e).tabIndex=t?0:-1}var zr=()=>document.querySelector("details#tocOnPage"),Vr=!1;function id(){Vr||(Vr=!0,co.addEventListener("change",()=>Yr(zr())))}var Kr=e=>`doc-h-${e}`;function rd(e,t,n,s,i){let r=n.findIndex(w=>w.slug===s),a=n[r],l=t.find(w=>w.id===a?.set);if(!a||!l){let w=t[0];N(`No doc named \u201C${s}\u201D, so here\u2019s the index.`),history.replaceState(history.state,"","#/docs"),w&&Gr(e,w,t,n);return}let d=n.filter(w=>w.set===a.set&&w.section===a.section),u=d.indexOf(a),m=d[u-1],y=d[u+1],v=(w,S)=>o`<a href="#/docs/${w.slug}" aria-label="${S}: ${w.title}">${S==="Previous"?o`<span aria-hidden="true">←</span><span class="doc-topnav-word">Previous</span>`:o`<span class="doc-topnav-word">Next</span><span aria-hidden="true">→</span>`}</a>`,k=m||y?o`<nav class="doc-topnav" aria-label="Adjacent docs">${m?v(m,"Previous"):null}${y?v(y,"Next"):null}</nav>`:null;h(e,_t("docs",o`<div id="docToc"></div>`,o`<article class="doc" id="docsMain"></article>`));let C=c("#docToc"),L=c("#docsMain");h(L,o`<section class="doc-part" id="doc-${a.slug}">${q(a.html)}</section>`);let I=c("section.doc-part",L),O=M("h1, h2, h3, h4",I),g=Wr(O.map(w=>w.textContent||""));for(let[w,S]of O.entries()){let B=g[w]??"";S.dataset.anchor=B,S.id=Kr(B)}let T=I.querySelector("h1"),p=[];if(T&&p.push({id:T.id,anchor:"",title:T.textContent||a.title}),k){let w=document.createElement("div");w.className="doc-head",h(w,k),T?(T.before(w),w.prepend(T)):I.prepend(w)}let x=document.createElement("p");x.className="eyebrow doc-set",h(x,o`<a href="#/docs/${l.id}">${l.name}</a>`),I.prepend(x);for(let w of M("h2",I))p.push({id:w.id,anchor:w.dataset.anchor||"",title:w.textContent||""});let _=document.createElement("nav");_.className="doc-pager",h(_,o`${m?o`<a class="doc-pager-btn prev" href="#/docs/${m.slug}"><span class="doc-pager-dir">← Previous</span><span class="doc-pager-title">${m.title}</span></a>`:o`<span></span>`}${y?o`<a class="doc-pager-btn next" href="#/docs/${y.slug}"><span class="doc-pager-dir">Next →</span><span class="doc-pager-title">${y.title}</span></a>`:o`<span></span>`}`),L.appendChild(_);let D=p.map(w=>o`<a class="toc-sub" href="#/docs/${a.slug}${w.anchor?`/${w.anchor}`:""}" data-target="${w.id}">${w.title}</a>`);h(C,p.length?o`<details class="toc-onpage" id="tocOnPage"><summary class="toc-label">On this page</summary>${D}</details>`:o``);let V=zr();Yr(V),id();let G=w=>{if(V)for(let S of M(".toc-sub",V))S.classList.toggle("on",S.dataset.target===w)};C.addEventListener("click",w=>{let S=w.target instanceof Element?w.target.closest("a[data-target]"):null,B=S?.dataset.target;B&&(w.preventDefault(),G(B),V&&co.matches&&(V.open=!1),history.replaceState(history.state,"",S?.getAttribute("href")??location.hash),document.getElementById(B)?.scrollIntoView({block:"start"}))});for(let w of M("a[href^='http']",L))w.target="_blank",w.rel="noopener";for(let w of M("pre",L)){w.classList.add("has-copy");let S=document.createElement("button");S.type="button",S.className="code-copy",S.textContent="Copy",S.addEventListener("click",async()=>{let B=w.querySelector("code");try{await navigator.clipboard.writeText((B||w).innerText),S.textContent="Copied",setTimeout(()=>{S.textContent="Copy"},1500)}catch{N("Couldn\u2019t copy to clipboard")}}),w.appendChild(S)}let U=p.map(w=>w.id),f=U[0];if(V&&f){let w=U[U.length-1]??f,B=(document.querySelector(".room-bar")?.getBoundingClientRect().height||54)+26,te=()=>{if(!document.getElementById(f)){document.onscroll=null;return}let F=f;if(Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight)F=w;else for(let oe of U)if((document.getElementById(oe)?.getBoundingClientRect().top??Number.POSITIVE_INFINITY)<=B)F=oe;else break;G(F)};document.onscroll=te,te()}let $=i?document.getElementById(Kr(i)):null;$?$.scrollIntoView({block:"start"}):window.scrollTo(0,0)}function ad(e){return e==="access"?['-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID"','-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"']:['-H "Authorization: Bearer $TOKEN"']}function us(e){return e.replace(/\([^)]*\)/g,"").replace(/\{[^}]*\}\??/g,"")||"/"}function Zr(e,t,n){if(e.access==="webhook")return null;let s=(e.query??[]).filter(l=>l.required),i=s.length?`?${s.map(l=>`${l.name}=:${l.name}`).join("&")}`:"",r=`"${t}${us(e.path)}${i}"`,a=[e.method==="GET"?`curl ${r}`:`curl -X ${e.method} ${r}`];if(e.access==="admin"&&a.push(...ad(n)),e.example?.request!==void 0){let l=JSON.stringify(e.example.request).replaceAll("'","'\\''");a.push('-H "Content-Type: application/json"',`-d '${l}'`)}return a.join(` \\
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
155
|
+
</div>`);let T=c("#f-subject"),$=c("#f-markdown"),p=c(".toolbar",t),x=c("#previewFrame"),R=c("#mdHl"),F=c("code",R),V=()=>{R.scrollTop=$.scrollTop,R.scrollLeft=$.scrollLeft},Z=()=>{b(F,sa($.value)),V()};$.addEventListener("scroll",V),Z();let U=()=>({subject:T.value,markdown:$.value}),v=null;function k(){v?.(),v=null}function S(){return T.value.trim()===""?(v=Me(T,"Add a subject before you schedule."),T.focus(),!1):(k(),!0)}let C=c("#headEmailOnly"),L=()=>{C.hidden=f||!h.emailOnly;let y=t.querySelector("#schedDestIcon");y&&b(y,$o(h));let P=t.querySelector("#schedDest");P&&b(P,So(h))},J=async y=>{let P=await H(`/api/posts/${e}/settings`,{method:"PUT",json:y});h=Ze(P.post,P.subject_slug_held),L(),P.changed&&Ue()},D=async()=>{try{let y=await H(`/api/posts/${e}`,{signal:n});h=Ze(y.post,y.subject_slug_held),L()}catch{}};c("#settingsBtn").onclick=()=>{let y={...h,subject:T.value},P=ue(o`<h3 id="psHead">Post settings</h3><p class="hint">Neither is part of the email, so both stay open while the post is scheduled.</p><div id="psForm"></div><div class="actions"><button type="button" id="psCancel">Cancel</button><button type="button" class="primary" id="psSave">Save</button></div>`,n),_=c(".modal",P.el);_.setAttribute("aria-labelledby","psHead"),$t(c("#psForm",_),y,"ps"),c("#psCancel",_).onclick=P.close;let B=c("#psSave",_);B.onclick=()=>G(B,"Saving\u2026",async()=>{P.clearError();let te=gn(h,y);if(!te){P.close();return}try{await J(te),P.close(),O("Post settings saved")}catch(le){if(n.aborted||Rt(le)){P.close();return}vn(_,"ps",le)||P.error(Je(le))}}),c("#psInclude",_).focus()},f||T.addEventListener("input",()=>{T.value.trim()!==""&&k()});let se=ra(()=>{Oe(!0).catch(()=>{})});ft(n,()=>se.cancel());let K=I(".ctab",t);function ie(y){for(let P of K){let _=P.dataset.tab===y;P.classList.toggle("active",_),P.setAttribute("aria-selected",_?"true":"false")}$.hidden=y!=="edit",R.hidden=y!=="edit",x.hidden=y!=="preview",p.classList.toggle("off",y!=="edit"),t.querySelector("#dropFoot")?.classList.toggle("off",y!=="edit"),y==="edit"&&V()}async function at(){ie("preview");try{f||await Oe(!0),x.srcdoc=await At(`/api/posts/${e}/preview`),x.onload=()=>{try{let y=x.contentDocument;y&&(Yt(y),ma(y,u.unlisted_url,A),x.style.height=`${y.body.scrollHeight+24}px`)}catch{}}}catch(y){if(n.aborted||Rt(y))return;O(Je(y))}}for(let y of K)y.onclick=()=>y.dataset.tab==="preview"?at():ie("edit");f&&at();let Y=f?c("#lockFoot"):null,re;function ce(){Y&&(Y.classList.remove("nudge"),requestAnimationFrame(()=>Y.classList.add("nudge")),clearTimeout(re),re=setTimeout(()=>Y.classList.remove("nudge"),900))}function $e(y,P,_){let B=$.selectionStart,te=$.selectionEnd,le=$.value.slice(B,te)||_;$.value=$.value.slice(0,B)+y+le+P+$.value.slice(te),$.focus(),$.selectionStart=B+y.length,$.selectionEnd=B+y.length+le.length}function _e(y){let P=$.selectionStart,_=$.selectionEnd,B=$.value.lastIndexOf(`
|
|
156
|
+
`,P-1)+1,te=($.value.slice(B,_)||"").split(`
|
|
157
|
+
`).map(le=>y+le).join(`
|
|
158
|
+
`);$.value=$.value.slice(0,B)+te+$.value.slice(_),$.focus(),$.selectionStart=B,$.selectionEnd=B+te.length}function Ne(y){if(f){ce();return}if(ie("edit"),y==="bold")$e("**","**","bold text");else if(y==="italic")$e("*","*","italic text");else if(y==="heading")_e("## ");else if(y==="quote")_e("> ");else if(y==="ul")_e("- ");else if(y==="ol")_e("1. ");else if(y==="indent")_e(" ");else if(y==="link")$e("[","](https://)","link text");else if(y==="code"){let P=$.selectionStart,_=$.selectionEnd;P===_||$.value.slice(P,_).includes(`
|
|
159
|
+
`)?$e("```\n","\n```","code"):$e("`","`","code")}Fs()}for(let y of I(".tb[data-fmt]",t))y.onclick=()=>Ne(y.dataset.fmt??"");$.addEventListener("keydown",y=>{if(!(y.metaKey||y.ctrlKey))return;let P=y.key.toLowerCase();P==="b"?(y.preventDefault(),Ne("bold")):P==="i"?(y.preventDefault(),Ne("italic")):P==="k"&&(y.preventDefault(),Ne("link"))});let Be=!1,lt=!1,de=!1,Qe=null,ct=0,Pn=!1,Os=0,Ae=f?null:c("#saveBtn"),Pe=f?null:c("#saveStatus"),Tt=new ws(U),Xt=!1;function Ds(){Pe&&(f?(Pe.className="save-status",Pe.textContent=""):Xt?(Pe.className="save-status is-saving",Pe.textContent="Saving\u2026"):Qe&&Be?(Pe.className="save-status is-error",Pe.textContent=`Not saved: ${Qe}`):Be?(Pe.className="save-status is-dirty",Pe.textContent="Unsaved changes"):(Pe.className="save-status is-saved",Pe.textContent="Saved"))}function Mn(){Be=Tt.dirty,Be||(Qe=null,lt=!1,ct=0),Ds()}Ds();function pi(){se.touch()}function Fs(){f||(Z(),Mn(),de||pi())}let Lt=Promise.resolve();function Oe(y){let P=Lt.catch(()=>{}).then(()=>Al(y));return Lt=P,ko(Lt),P}async function Al(y){if(y&&!Tt.dirty)return"unchanged";if(de)return"refused";se.cancel(),Xt=!0,Ds();let P=U(),_=JSON.stringify(P);try{let B={...P,base_revision:w.base},{post:te,subject_slug_held:le}=await H(`/api/posts/${e}`,{method:"PUT",json:B});return h=Ze(te,le),L(),w.saved(te.current_revision),Tt.markSaved(_),lt=!1,Qe=null,ct=0,y||O("Saved"),"saved"}catch(B){let te=ca(B);if(te)return hi(te),"refused";if(Ie(B))return a(),"refused";lt=!0;let le=B instanceof ee&&B.field==="subject"?T:void 0;if(le?(Me(le,q(B)),Qe="fix the field marked above."):Qe=q(B),!(Js(B)||B instanceof ee&&(B.status>=500||B.status===429)))Os+=1,Pn=!1,ct=0;else if(!Pn){Pn=!0,ct=ct?Math.min(ct*2,6e4):5e3;let oe=Os;mt(Date.now()+ct,()=>{oe===Os&&(Pn=!1,Qe&&!de&&Oe(!0).catch(()=>{}))},n)}throw B}finally{Xt=!1,Mn()}}Ae&&(Ae.onclick=()=>{if(de){We.scrollIntoView({block:"nearest"});return}G(Ae,"Saving\u2026",()=>Oe(!1).catch(()=>{})).finally(Mn)});function mi(){Ae&&(de?(Ae.setAttribute("aria-disabled","true"),Ae.title="Saving is paused: choose Reload or Keep editing above"):(Ae.removeAttribute("aria-disabled"),Ae.removeAttribute("title")))}let Pl=()=>{if(se.cancel(),f||!Tt.dirty)return;let y=U(),P=JSON.stringify(y);Be=!1,Lt=Lt.catch(()=>{}).then(async()=>{if(!Tt.dirty)return;let _={...y,base_revision:w.base},{post:B}=await H(`/api/posts/${e}`,{method:"PUT",json:_});w.saved(B.current_revision),Tt.markSaved(P)}).catch(_=>{if(Ie(_)){O("This post was deleted elsewhere, so your last edits weren\u2019t saved.");return}O(_ instanceof ee&&_.status===409?"Changed elsewhere, so your edits weren\u2019t saved":`Couldn\u2019t save your changes: ${Je(_)}`)}),ko(Lt)},Ml={dirty:()=>Be,beforeLeave(){return i?"leave":Be&&(lt||de)?"confirm":(Pl(),"leave")},manualSave(){Ae&&!Ae.disabled&&Ae.click()}};if(!f){for(let y of[T,$])y.addEventListener("input",Fs);for(let y of[T])y.addEventListener("blur",()=>Oe(!0).catch(()=>{})),y.addEventListener("keydown",P=>{P.key==="Enter"&&!P.isComposing&&P.keyCode!==229&&(P.preventDefault(),Oe(!0).catch(()=>{}))})}let We=c("#freshnessBanner"),Il=y=>y==="service"?"Claude":y||null;function fi(){de=!1,mi(),w.clearWarning(),We.hidden=!0,b(We,o``)}function hi(y){if(f)return;if(de=!0,Qe=null,mi(),w.noteWarned(y),y.kind==="locked")b(We,o`<span><span aria-hidden="true">⚠️</span> This draft was scheduled elsewhere and can no longer be edited here.</span><span class="row"><button type="button" class="ghost" id="freshReload">Reload</button></span>`);else{let _=Il(y.author);b(We,o`<span><span aria-hidden="true">⚠️</span> This draft was changed elsewhere${_?o`, last edited by <strong>${_}</strong>`:null}. Saving is paused until you choose: Reload to load that version (discards your unsaved edits), or Keep editing to keep yours, and your next save overwrites that version.</span><span class="row"><button type="button" class="ghost" id="freshReload">Reload</button><button type="button" class="ghost" id="freshKeep">Keep editing</button></span>`)}We.hidden=!1,c("#freshReload",We).onclick=()=>{fi(),s()};let P=We.querySelector("#freshKeep");P&&(P.onclick=()=>{w.adoptWarned(),fi(),Mn(),Be&&pi()})}function Rt(y){return Ie(y)?(a(),!0):!1}if(!f){let y=async()=>{if(Xt||de||i||document.hidden)return;let P=w.base;try{let _=await H(`/api/posts/${e}`,{signal:n}),B=w.decide({status:_.post.status,revision:_.post.current_revision,author:_.post.author},{saving:Xt,baseAtRequest:P});B!=="ignore"&&B!=="fresh"&&hi(B)}catch(_){Ie(_)&&a()}};pt(1e4,y,n),_d(y,n)}if(c("#webLink").addEventListener("click",()=>{f||Oe(!0).catch(()=>{})}),f&&d){let y=l?.send??{...d,conditions:[]},P=c("#schedWhen"),_=c("#schedControls"),B="",te=0,le=!1,xe=c("#rescheduleSchedule"),oe=c("#cancelSchedule"),z=me=>y.actions===void 0||Ye({actions:y.actions},me),we=()=>{let me=Date.now(),Ee=y.phase==="due"||me>=y.fire_at;Ee?le||(xe.hidden=!0,oe.hidden=!0):(le=!0,xe.hidden=!z("reschedule"),oe.hidden=!z("cancel")),xe.disabled=Ee,oe.disabled=Ee,_.hidden=xe.hidden&&oe.hidden;let Te=kd(y,Ee),ut=String(Te);ut!==B&&(B=ut,b(P,Te)),!Ee&&y.fire_at-me<=1e3&&y.fire_at!==te&&(te=y.fire_at,mt(y.fire_at,we,n))};we(),pt(1e3,we,n);let dt=c("#schedRemade"),De=c("#schedUntested"),ke=c("#schedDailyWrites"),Ct=()=>{let me=fe(y,"remade");dt.hidden=!me,c("#schedRemadeMsg").textContent=me?.message??"";let Ee=fe(y,"untested");De.hidden=!Ee,c("#schedUntestedMsg").textContent=Ee?.message??"";let Te=fe(y,"daily_writes");ke.hidden=!Te,c("#schedDailyWritesMsg").textContent=Te?.message??""};Ct();let en=me=>ln(me,{update(Ee,Te){let ut=!Te&&Ee.rev!==y.rev;if(y=Ee,ut&&D(),Te?.to==="sending"||Te?.to==="sent"||Te?.to==="complete"){O("Your send started, so this page switched to its live watch."),$s(`#/sent/${Ee.id}`);return}if(Te?.to==="canceled"){O("The schedule was canceled elsewhere, so this post is a draft again."),s();return}we(),Ct()},removed:a,stale:s},n);l?en(l):wi(5e3,async()=>{let me=await H(`/api/sends/${d.id}`,{signal:n});return fa(me.send,s)||(y=me.send,we(),Ct(),en(me)),!1},n),xe.onclick=()=>{vs({id:y.id,post_id:e,fire_at:y.fire_at},s,n,{...h})},oe.onclick=()=>G(oe,"Canceling\u2026",async()=>{try{await H(`/api/sends/${d.id}/cancel`,{method:"POST"}),O("Schedule canceled"),s()}catch(me){Ie(me)?a():me instanceof ee&&me.code==="window_closed"?(y={...y,phase:"due"},O("Too late to cancel: the send time has passed, so this email is going out."),Ue()):O(Je(me))}}).finally(we)}async function Us(y){if(y.type.startsWith("image/"))try{let P=new FormData;P.append("file",y);let{image:_}=await H(`/api/posts/${e}/images`,{method:"POST",body:P}),B=$.selectionStart,te=`
|
|
160
|
+

|
|
161
|
+
`;$.value=$.value.slice(0,B)+te+$.value.slice(B),$.selectionStart=$.selectionEnd=B+te.length,Fs(),O("Image added")}catch(P){Rt(P)||O(Je(P))}}let Ve=c("#composerBody");if(f){let y=_=>_.metaKey||_.ctrlKey?["x","z","y"].includes(_.key.toLowerCase()):_.key.length===1||_.key==="Enter"||_.key==="Backspace"||_.key==="Delete",P=[$,T];for(let _ of P)_.addEventListener("keydown",B=>{y(B)&&ce()}),_.addEventListener("paste",ce);Ve.addEventListener("dragover",_=>_.preventDefault()),Ve.addEventListener("drop",_=>{_.preventDefault(),ce()})}else{let y=c("#imgInput"),P=c("#dropFoot");Ve.addEventListener("dragover",_=>{_.preventDefault(),Ve.classList.add("dragover")}),Ve.addEventListener("dragleave",_=>{_.target===Ve&&Ve.classList.remove("dragover")}),Ve.addEventListener("drop",_=>{_.preventDefault(),Ve.classList.remove("dragover"),ie("edit");for(let B of _.dataTransfer?.files??[])Us(B)}),$.addEventListener("paste",_=>{for(let B of _.clipboardData?.items||[])if(B.type.startsWith("image/")){let te=B.getAsFile();te&&(_.preventDefault(),Us(te))}}),P.onclick=()=>y.click(),y.onchange=()=>{for(let _ of y.files??[])Us(_);y.value=""}}let Nl=c("#warnings");function Bl(y){b(Nl,y?.length?o`<div class="warnings"><strong>Warnings:</strong> ${y.join("; ")}</div>`:o``)}c("#testBtn").onclick=()=>{let y=f?"Delivers the frozen copy that will send, exactly as it will fire, so you can check it in a client. One address per line.":"Delivers the rendered email to real inboxes so you can check it in a client. One address per line.",P=ge(),_=ue(o`<h3>Send a test</h3><p class="hint">${y}</p>${es()}${P?null:o`<label for="testTo">Recipients</label><textarea id="testTo" rows="3" placeholder="you@example.com"></textarea><p class="hint" id="testDefaultsHint" hidden></p>`}<div class="actions"><button type="button" id="tCancel">Cancel</button><button type="button" class="primary" id="tGo">Send test</button></div>`,n),B=c("#tGo",_.el);if(c("#tCancel",_.el).onclick=_.close,B.disabled=P,P){c("#tCancel",_.el).focus();return}let te=c("#testTo",_.el);te.focus(),H("/api/settings").then(le=>{let xe=le.settings.test_recipients||[];if(xe.length&&!te.value.trim()){te.value=xe.join(`
|
|
162
|
+
`),_.clearError();let oe=c("#testDefaultsHint",_.el);oe.textContent="Pre-filled from your default test recipients (Settings).",oe.hidden=!1}}).catch(()=>{}),B.onclick=()=>G(B,"Sending\u2026",async()=>{_.clearError();let{valid:le,invalid:xe}=is(te.value),oe=rs(xe);if(oe){_.error(oe,te);return}if(!le.length){_.error("Enter at least one email address",te);return}try{f||await Oe(!0);let z=0,we=[],dt=null;for(let De of le)try{let ke=await H(`/api/posts/${e}/test`,{method:"POST",json:{to:De}});ke.sent?z++:we.push({email:De,message:ke.not_sent?.message??""}),dt=ke.warnings}catch(ke){if(Ie(ke)||ke instanceof ee&&ke.status===401)throw ke;we.push({email:De,message:q(ke)})}if(Bl(dt),f&&z>0&&Ue(),we.length){te.value=we.map(De=>De.email).join(`
|
|
163
|
+
`),_.error(as(z,le.length,we));return}_.close(),O(Mt(`Test sent to ${z} address${z===1?"":"es"}`))}catch(z){if(Ie(z)){_.close(),Rt(z);return}_.error(Je(z))}})};let bi=f?null:c("#scheduleBtn");return bi&&(bi.onclick=()=>{if(!S())return;let y=yt(zn()),P=yt(new Date(Date.now()+24*3600*1e3)),_=!g,B={...h,subject:T.value};B.slugAuto&&!B.slugTaken&&(B.slug=bs(B.subject)||B.slug);let te="It sends at the time you pick, with a cancelable window until then.",le=()=>o`<h3 id="schHead">Schedule this post</h3><p class="hint" id="schHint">${te}</p>${Pi()}${ro()}${_?null:o`<label for="schWhen">Send at</label><input type="datetime-local" id="schWhen" min="${y}" value="${P}"><div class="dialog-settings" id="schForm"></div>`}<div class="actions"><button type="button" id="schCancel">Cancel</button><button type="button" class="primary" id="schGo">Schedule</button></div>${_?null:o`<div class="altrow"><span class="altrow-note">Or send as soon as possible.</span><button type="button" class="linkbtn" id="toSendNow">Send now →</button></div>`}`,xe=P,oe=ue(le(),n),z=c(".modal",oe.el),we=null,dt=()=>(we??=H("/api/subscribers?status=confirmed&suppressed=hide&limit=1",{signal:n}).then(pe=>pe.page.total).catch(()=>null),we),De=async()=>{if(!z.querySelector("#schDailyWrites"))return;let pe=await dt(),Ce=z.querySelector("#schDailyWrites");Ce&&pe!==null&&pe>wo&&(Ce.textContent=zr(pe),Ce.hidden=!1)},ke=()=>{oe.close(),We.scrollIntoView({block:"nearest"})},Ct=()=>$t(c("#schForm",z),B,"sch"),en=()=>({email_only:B.emailOnly,...gn(h,B)}),me=pe=>{vn(z,"sch",pe)||oe.error(Je(pe))},Ee=()=>G(c("#schGo",z),"Scheduling\u2026",async()=>{oe.clearError();let pe=c("#schWhen",z),Ce=pe.value?new Date(pe.value).getTime():Number.NaN;if(Number.isNaN(Ce)){oe.error("Pick a valid date & time",pe);return}if(Yn(Ce)){oe.error(Gn(),pe);return}try{if(await Oe(!0)==="refused"){ke();return}await H(`/api/posts/${e}/schedule`,{method:"POST",json:{fire_at:new Date(Ce).toISOString(),...en()}}),oe.close(),O(Mt(`Scheduled for ${W(Ce)}. Send yourself a test.`)),s()}catch(Ke){if(Ie(Ke)){oe.close(),Rt(Ke);return}if(Ke instanceof ee&&Ke.field==="fire_at"){oe.error(Je(Ke),c("#schWhen",z));return}me(Ke)}}),Te=()=>G(c("#snGo",z),"Queuing\u2026",async()=>{oe.clearError();try{if(await Oe(!0)==="refused"){ke();return}let{send:pe}=await H(`/api/posts/${e}/send`,{method:"POST",json:en()});oe.close(),O(Mt(`Sends at ${W(pe.fire_at)}, cancelable until then.`)),s()}catch(pe){if(Ie(pe)){oe.close(),Rt(pe);return}me(pe)}});function ut(){if(z.setAttribute("aria-labelledby","schHead"),c("#schCancel",z).onclick=oe.close,c("#schGo",z).onclick=Ee,c("#schGo",z).disabled=_,_){c("#schCancel",z).focus();return}c("#toSendNow",z).onclick=Ol,c("#schWhen",z).value=xe,Ct(),c("#schWhen",z).focus(),De()}async function Ol(){let pe=z.querySelector("#schWhen");pe&&(xe=pe.value),b(z,o`<h3 id="snHead">Send now?</h3><p class="hint">Sends this draft, as it is now, to <strong id="snWho">your subscribers</strong> in ${Hi()}. The post locks until then, and you can cancel any time before it starts sending.</p><div class="dialog-settings" id="schForm"></div>${ro()}<div class="actions"><button type="button" class="linkbtn actions-back" id="toSchedule">← Back to schedule</button><button type="button" id="snCancel">Cancel</button><button type="button" class="primary" id="snGo">Send now</button></div>`),z.setAttribute("aria-labelledby","snHead"),c("#snCancel",z).onclick=oe.close,c("#snGo",z).onclick=Te,Ct(),c("#snGo",z).disabled=!Vt(u,"send_now"),c("#toSchedule",z).onclick=()=>{b(z,le()),ut()},c("#snGo",z).focus(),De();let Ce=await dt(),Ke=z.querySelector("#snWho");Ke&&Ce!==null&&(Ke.textContent=`${Ce} recipient${Ce===1?"":"s"}`)}ut()}),Ml}function fa(e,t){return e.status==="sending"||e.status==="sent"?($s(`#/sent/${e.id}`),!0):e.status==="canceled"?(t(),!0):!1}function kd(e,t){return Le(e,"missed")?o`Scheduled for <strong>${W(e.fire_at)}</strong> · <span class="countdown countdown-missed">${ls(e.fire_at)}</span>`:t?o`Scheduled for <strong>${W(e.fire_at)}</strong> · <span class="countdown">${cn(e.fire_at,!0)}</span>`:o`Scheduled for <strong>${W(e.fire_at)}</strong> · <span class="countdown">${cn(e.fire_at)}</span>`}function Ie(e){return e instanceof ee&&e.status===404}function Ed(e){let t=e.post.send;return t?.status==="sending"?`#/sent/${t.id}`:e.post.status==="sent"?t?`#/sent/${t.id}`:"#/sent":null}function $s(e){location.replace(e)}function _d(e,t){document.addEventListener("visibilitychange",()=>{document.hidden||e()},{signal:t})}function Td(e){return e.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu,"").replace(/\s/g,"-")}function ha(e){let t=new Map;return e.map(n=>{let s=Td(n),i=t.get(s)??0;return t.set(s,i+1),i?`${s}-${i}`:s})}var To=null;function Ld(e){let t=document.createElement("div");b(t,j(e.html||""));let n=(t.querySelector("p")?.textContent||"").trim().replace(/\s+/g," "),s=/[:;,]$/.test(n),i=n.replace(/[\s:;,]+$/,""),a=150;return i.length>a?`${i.slice(0,a).replace(/[\s.,;:]+\S*$/,"")}\u2026`:s?`${i}\u2026`:i}async function Lo(e,t,n,s){if(b(t,It("docs",null,o`<div class="docs-index"><p class="muted">Loading…</p></div>`)),!To)try{To=await H("/api/docs",{signal:n})}catch(d){n.aborted||ne(c(".room-main",t),q(d),()=>Q((u,m)=>Lo(e,u,m,s)));return}let{sets:i,docs:a}=To,r=i[0];if(!a.length||!r){b(c(".room-main"),o`<p class="muted">No documentation.</p>`);return}let l=e?i.find(d=>d.id===e):r;l?va(t,l,i,a):e&&Hd(t,i,a,e,s?Rd(s):void 0)}function Rd(e){try{return decodeURIComponent(e)}catch{return}}function va(e,t,n,s){let i=(h,f)=>{let g=Ld(h);return o`<li><a class="doc-card" href="#/docs/${h.slug}">${f===null?null:o`<span class="doc-card-n">${String(f).padStart(2,"0")}</span>`}<span class="doc-card-main"><span class="doc-card-t">${h.title} <span class="doc-card-go" aria-hidden="true">→</span></span>${g?o`<span class="doc-card-d">${g}</span>`:null}</span></a></li>`},a=t.sections.map(h=>{let f=s.filter(E=>E.set===t.id&&E.section===h.id);if(!f.length)return null;let g=f.map((E,M)=>i(E,h.numbered?M+1:null)),w=h.blurb?o`<div class="doc-section-blurb">${j(h.blurb)}</div>`:null;return o`<section class="doc-section"><h2 class="doc-section-t">${h.title}</h2>${w}${h.numbered?o`<ol class="doc-cards">${g}</ol>`:o`<ul class="doc-cards">${g}</ul>`}</section>`}),r=o`<nav class="docs-tabs" aria-label="Doc sets">${n.map(h=>o`<a href="#/docs/${h.id}" title="${h.title}"${h.id===t.id?o` aria-current="page"`:null}>${h.name}</a>`)}</nav>`,l=o`<div class="docs-index"><h1>Kestrel docs</h1><p class="docs-index-intro"><strong>Publishing</strong> covers writing and sending your newsletter, from naming your publication to what happens after each send. <strong>Self-hosting</strong> covers deploying Kestrel to your own Cloudflare account and keeping it running. One person often does both.</p>${r}${a}</div>`,d=X.appConfig?.deployment.build.repo_url||"",u=(h,f,g=f)=>o`<a class="rail-link" href="${h}" target="_blank" rel="noopener" aria-label="${f}"><span class="rail-link-full">${f}</span><span class="rail-link-short" aria-hidden="true">${g}</span> <span aria-hidden="true">↗</span></a>`,m=o`<div class="toc-label">Kestrel</div>${u("https://getkestrel.dev","Project site","Project")}${d?[u(d,"Source on GitHub","Source"),u(`${d}/blob/HEAD/LICENSE`,"License")]:null}`;b(e,It("docs",m,l)),window.scrollTo(0,0)}var Ro=matchMedia("(max-width: 720px)");function ya(e){if(!e)return;let t=Ro.matches;e.open=!t,c("summary",e).tabIndex=t?0:-1}var wa=()=>document.querySelector("details#tocOnPage"),ba=!1;function Cd(){ba||(ba=!0,Ro.addEventListener("change",()=>ya(wa())))}var ga=e=>`doc-h-${e}`;function Hd(e,t,n,s,i){let a=n.findIndex(S=>S.slug===s),r=n[a],l=t.find(S=>S.id===r?.set);if(!r||!l){let S=t[0];O(`No doc named \u201C${s}\u201D, so here\u2019s the index.`),history.replaceState(history.state,"","#/docs"),S&&va(e,S,t,n);return}let d=n.filter(S=>S.set===r.set&&S.section===r.section),u=d.indexOf(r),m=d[u-1],h=d[u+1],f=(S,C)=>o`<a href="#/docs/${S.slug}" aria-label="${C}: ${S.title}">${C==="Previous"?o`<span aria-hidden="true">←</span><span class="doc-topnav-word">Previous</span>`:o`<span class="doc-topnav-word">Next</span><span aria-hidden="true">→</span>`}</a>`,g=m||h?o`<nav class="doc-topnav" aria-label="Adjacent docs">${m?f(m,"Previous"):null}${h?f(h,"Next"):null}</nav>`:null;b(e,It("docs",o`<div id="docToc"></div>`,o`<article class="doc" id="docsMain"></article>`));let w=c("#docToc"),E=c("#docsMain");b(E,o`<section class="doc-part" id="doc-${r.slug}">${j(r.html)}</section>`);let M=c("section.doc-part",E),A=I("h1, h2, h3, h4",M),T=ha(A.map(S=>S.textContent||""));for(let[S,C]of A.entries()){let L=T[S]??"";C.dataset.anchor=L,C.id=ga(L)}let $=M.querySelector("h1"),p=[];if($&&p.push({id:$.id,anchor:"",title:$.textContent||r.title}),g){let S=document.createElement("div");S.className="doc-head",b(S,g),$?($.before(S),S.prepend($)):M.prepend(S)}let x=document.createElement("p");x.className="eyebrow doc-set",b(x,o`<a href="#/docs/${l.id}">${l.name}</a>`),M.prepend(x);for(let S of I("h2",M))p.push({id:S.id,anchor:S.dataset.anchor||"",title:S.textContent||""});let R=document.createElement("nav");R.className="doc-pager",b(R,o`${m?o`<a class="doc-pager-btn prev" href="#/docs/${m.slug}"><span class="doc-pager-dir">← Previous</span><span class="doc-pager-title">${m.title}</span></a>`:o`<span></span>`}${h?o`<a class="doc-pager-btn next" href="#/docs/${h.slug}"><span class="doc-pager-dir">Next →</span><span class="doc-pager-title">${h.title}</span></a>`:o`<span></span>`}`),E.appendChild(R);let F=p.map(S=>o`<a class="toc-sub" href="#/docs/${r.slug}${S.anchor?`/${S.anchor}`:""}" data-target="${S.id}">${S.title}</a>`);b(w,p.length?o`<details class="toc-onpage" id="tocOnPage"><summary class="toc-label">On this page</summary>${F}</details>`:o``);let V=wa();ya(V),Cd();let Z=S=>{if(V)for(let C of I(".toc-sub",V))C.classList.toggle("on",C.dataset.target===S)};w.addEventListener("click",S=>{let C=S.target instanceof Element?S.target.closest("a[data-target]"):null,L=C?.dataset.target;L&&(S.preventDefault(),Z(L),V&&Ro.matches&&(V.open=!1),history.replaceState(history.state,"",C?.getAttribute("href")??location.hash),document.getElementById(L)?.scrollIntoView({block:"start"}))});for(let S of I("a[href^='http']",E))S.target="_blank",S.rel="noopener";for(let S of I("pre",E)){S.classList.add("has-copy");let C=document.createElement("button");C.type="button",C.className="code-copy",C.textContent="Copy",C.addEventListener("click",async()=>{let L=S.querySelector("code");try{await navigator.clipboard.writeText((L||S).innerText),C.textContent="Copied",setTimeout(()=>{C.textContent="Copy"},1500)}catch{O("Couldn\u2019t copy to clipboard")}}),S.appendChild(C)}let U=p.map(S=>S.id),v=U[0];if(V&&v){let S=U[U.length-1]??v,L=(document.querySelector(".room-bar")?.getBoundingClientRect().height||54)+26,J=()=>{if(!document.getElementById(v)){document.onscroll=null;return}let D=v;if(Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight)D=S;else for(let se of U)if((document.getElementById(se)?.getBoundingClientRect().top??Number.POSITIVE_INFINITY)<=L)D=se;else break;Z(D)};document.onscroll=J,J()}let k=i?document.getElementById(ga(i)):null;k?k.scrollIntoView({block:"start"}):window.scrollTo(0,0)}function Co(e){let t=new Set(e.groups.flatMap(n=>n.routes.flatMap(s=>(s.errors??[]).map(i=>i.code))));return e.refusals.flatMap(n=>n.codes.filter(s=>!t.has(s.code)).map(s=>({...s,move:n.move})))}function kt(e){return e.replace(/\([^)]*\)/g,"").replace(/\{[^}]*\}\??/g,"")||"/"}function zt(e){return e.title.toLowerCase().replace(/'s\b/g,"").split(/[^a-z0-9]+/).filter(t=>t!==""&&t!=="a"&&t!=="an"&&t!=="the").join("-")}var Ad=/`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;function Pd(e){return/^https:\/\//i.test(e)||/^\/(?![/\\])/.test(e)}function be(e){let t=[],n=0;for(let s of e.matchAll(Ad)){let i=s.index??0;t.push(e.slice(n,i));let[,a,r,l,d]=s;a!==void 0?t.push(o`<code>${a}</code>`):r!==void 0?t.push(o`<strong>${be(r)}</strong>`):l!==void 0&&d!==void 0&&t.push(Pd(d)?o`<a href="${d}">${be(l)}</a>`:be(l)),n=i+s[0].length}return t.push(e.slice(n)),o`${t}`}function yn(e,t){return o`${e.split(/\n\s*\n/).filter(n=>n.trim()).map(n=>{let s=n.trim().split(`
|
|
164
|
+
`),i=s.findIndex(l=>l.startsWith("- "));if(i===-1)return o`<p class="${t}">${be(n.trim())}</p>`;let a=s.slice(0,i).join(" ").trim(),r=s.slice(i).reduce((l,d)=>(d.startsWith("- ")||l.length===0?l.push(d.replace(/^- /,"")):l[l.length-1]=`${l[l.length-1]} ${d.trim()}`,l),[]);return o`${a?o`<p class="${t}">${be(a)}</p>`:null}<ul class="${t} prose-list">${r.map(l=>o`<li>${be(l)}</li>`)}</ul>`})}`}function Md(e){return e==="access"?['-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID"','-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"']:['-H "Authorization: Bearer $TOKEN"']}function Sa(e,t,n){if(e.access==="webhook")return null;let s=(e.query??[]).filter(l=>l.required),i=s.length?`?${s.map(l=>`${l.name}=:${l.name}`).join("&")}`:"",a=`"${t}${kt(e.path)}${i}"`,r=[e.method==="GET"?`curl ${a}`:`curl -X ${e.method} ${a}`];if(e.access==="admin"&&r.push(...Md(n)),e.example?.request!==void 0){let l=JSON.stringify(e.example.request).replaceAll("'","'\\''");r.push('-H "Content-Type: application/json"',`-d '${l}'`)}else if(e.accepts?.includes("multipart/form-data"))for(let l of(e.body??[]).filter(d=>d.required))r.push(`-F "${l.name}=${l.type==="file"?"@":""}:${l.name}"`);return r.join(` \\
|
|
165
|
+
`)}function Ao(e,t){return t===void 0?null:Kt(e,oa(t),JSON.stringify(t,null,2))}function Id(e){let t=Sa(e,location.origin,X.session?.auth.mode??"dev");return t===null?null:Kt("curl",ia(t),t)}function xs(e){return o`${e.split(/(?<=[_./])/).map((t,n)=>o`${n?o`<wbr>`:null}${t}`)}`}function it(e,t,n,s){return o`<div class="block api-sec"><div class="block-head"><span class="block-label">${e}${n?o`<span class="api-sec-meta">${n}</span>`:null}</span></div>${s??null}${t}</div>`}function Po(e,t,n,s){return o`<div class="api-row"><div class="api-row-head"><code class="api-row-name">${xs(e)}</code>${t?o`<span class="api-row-type">${t}</span>`:null}${n?o`<span class="api-req">Required</span>`:null}</div><div class="api-row-desc">${s}</div></div>`}function Mo(e,t,n,s){return o`<div class="api-lrow"><span class="api-st api-st-${String(e)[0]}xx">${e}</span><div><div class="api-row-head"><code class="api-row-name">${t}</code></div>${n?o`<div class="api-with">${n}</div>`:null}<div class="api-row-desc">${s}</div></div></div>`}function Nd(e){if(!e?.length)return null;let t=e.map(n=>Po(n.name,null,n.required,be(n.description)));return it("Query",o`<div class="api-list api-query">${t}</div>`)}function Bd(e,t){if(!e?.length)return null;let n=e.map(i=>Po(i.name,i.type,i.required,be(i.description))),s=t?.length?o` · ${t.map((i,a)=>o`${a?", ":""}<code>${i}</code>`)}`:null;return it("Body",o`<div class="api-list api-body">${n}</div>`,s)}function Od(e,t){if(!e?.length)return null;let n=e.map(s=>Mo(s.status,xs(s.code),s.details?.length?o`with ${s.details.map((i,a)=>o`${a?", ":""}<code>${i}</code>`)}`:null,o`${be(s.when??t.get(s.code)??"")}<span class="api-move-next">${s.move}</span>`));return it("Refusals",o`<div class="api-ledger api-errors">${n}</div>`)}function Dd(e){let t=s=>o`<ul class="api-values">${s.map(i=>o`<li><code>${i.value}</code> ${be(i.description)}</li>`)}</ul>`,n=[...new Set(e.map(s=>s.group).filter(s=>s!==void 0))];return n.length?o`${n.map(s=>o`<div class="api-values-group">${be(s)}</div>${t(e.filter(i=>i.group===s))}`)}`:t(e)}function Io(e){let t=e.map(n=>Po(n.name,n.object?o`<a href="#/api-reference/objects-${n.object}" class="api-obj-link" data-sec="objects-${n.object}">${n.type}</a>`:n.type,!1,o`${be(n.description)}${n.values?.length?Dd(n.values):null}`));return o`<div class="api-list api-fields">${t}</div>`}function Fd(e,t){if(!e)return null;let n=e.object?o`<a href="#/api-reference/objects-${e.object}" class="api-obj-link" data-sec="objects-${e.object}">${t.get(e.object)??e.object}</a>`:null,s=n?e.as?o`<p class="api-returns"><code>${e.as}</code>: ${e.list?o`a list of ${n}`:n}${e.fields?.length?", and:":null}</p>`:o`<p class="api-returns">${n}${e.fields?.length?", and:":null}</p>`:null;return it("Returns",e.fields?.length?Io(e.fields):null,null,s)}function Ud(e){return o`${kt(e).split(/(:[A-Za-z_]+)/).map((t,n)=>n%2?o`<span class="api-param">${t}</span>`:t)}`}function wn(e,t){return"route"in t?o`<a class="api-self" href="#/api-reference/${t.route}" data-route="${t.route}">${N("link")}${e}</a>`:o`<a class="api-self" href="#/api-reference/${t.sec}" data-sec="${t.sec}">${N("link")}${e}</a>`}function qd(e){let t=new Map;for(let n of e.objects){let s=e.groups.find(i=>i.resources.some(a=>a.key===n.resource));if(s){let i=`${s.access}-${n.resource}`;t.set(i,[...t.get(i)??[],n])}}return t}function jd(e,t){let n=e.returns?.object;return[e.title,e.method,kt(e.path),e.summary,...(e.errors??[]).map(s=>s.code),...n?[n,t.objects.get(n)??"",e.returns?.as??""]:[]].join(" ").toLowerCase()}function Wd(e,t){return o`<li data-hay="${jd(e,t)}" data-path="${kt(e.path).toLowerCase()}" id="route-${zt(e)}" data-route="${zt(e)}"><article class="api-op">
|
|
166
|
+
<div class="api-op-head">
|
|
167
|
+
<h4 tabindex="-1">${wn(e.title,{route:zt(e)})}</h4>
|
|
168
|
+
</div>
|
|
169
|
+
<p class="api-op-call"><span class="api-method m-${e.method}">${e.method}</span><code class="api-path">${Ud(e.path)}</code><button type="button" class="copy-btn api-copy-path" data-copy="${kt(e.path)}" data-copy-word="" aria-label="Copy path" title="Copy path">${Re(!1,"")}</button></p>
|
|
170
|
+
<div class="api-op-body">
|
|
171
|
+
<div class="api-op-text">
|
|
172
|
+
<p class="api-summary">${be(e.summary)}</p>
|
|
173
|
+
${e.description?yn(e.description,"api-desc"):null}
|
|
174
|
+
${Nd(e.query)}
|
|
175
|
+
${Bd(e.body,e.accepts)}
|
|
176
|
+
${Fd(e.returns,t.objects)}
|
|
177
|
+
${Od(e.errors,t.meanings)}
|
|
178
|
+
</div>
|
|
179
|
+
<div class="api-op-code">
|
|
180
|
+
${Ao("Request",e.example?.request)}
|
|
181
|
+
${Ao("Response",e.example?.response)}
|
|
182
|
+
${Id(e)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</article></li>`}var Ea=(e,t)=>e.routes.filter(n=>n.resource===t.key),Vd=80;function Kd(e){let t=e.columns.map((i,a)=>a>0&&e.rows.every(r=>(r[a]??"").length<=Vd)),n=e.columns.findIndex((i,a)=>a>0&&!t[a]),s=e.rows.map(i=>{let a=/^`([^`]+)`$/.exec(i[0]??"")?.[1],r=e.columns.flatMap((d,u)=>t[u]?[o`<span class="api-row-meta"><span class="api-row-key">${d}</span> ${be(i[u]??"")}</span>`]:[]),l=e.columns.flatMap((d,u)=>u>0&&!t[u]?[o`<div class="api-row-desc">${u===n?null:o`<span class="api-row-key">${d}:</span> `}${be(i[u]??"")}</div>`]:[]);return o`<div class="api-row"><div class="api-row-head"><code class="api-row-name">${a===void 0?be(i[0]??""):xs(a)}</code>${r}</div>${l}</div>`});return it(e.title,o`<div class="api-list api-value-rows">${s}</div>`,null,yn(e.description,"api-prose"))}function Gd(e){return o`<article class="api-op api-object" id="api-objects-${e.key}" data-sec="objects-${e.key}">
|
|
186
|
+
<div class="api-op-head"><h4 tabindex="-1">${wn(e.title,{sec:`objects-${e.key}`})}</h4></div>
|
|
187
|
+
<p class="api-op-call"><span class="api-method m-OBJECT">Object</span><span class="api-path">${e.fields.length} ${e.fields.length===1?"field":"fields"}</span></p>
|
|
188
|
+
<div class="api-op-body">
|
|
189
|
+
<div class="api-op-text">
|
|
190
|
+
${yn(e.description,"api-prose")}
|
|
191
|
+
${Io(e.fields)}
|
|
192
|
+
${(e.tables??[]).map(Kd)}
|
|
193
|
+
</div>
|
|
194
|
+
<div class="api-op-code">${Ao("Example",e.example)}</div>
|
|
192
195
|
</div>
|
|
193
|
-
</
|
|
194
|
-
<div class="api-res-head"><h3>${t.title}</h3></div>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
196
|
+
</article>`}function Yd(e,t,n){let s=`${e.access}-${t.key}`,i=Ea(e,t),a=n.heads.get(s)??[];return o`<div class="api-res" id="api-${s}" data-sec="${s}">
|
|
197
|
+
<div class="api-res-head"><h3 tabindex="-1">${wn(t.title,{sec:s})}</h3></div>
|
|
198
|
+
${a.map(Gd)}
|
|
199
|
+
<ul class="api-rows">${i.map(r=>Wd(r,n))}</ul>
|
|
200
|
+
</div>`}function zd(e,t){return o`<section class="api-section" id="api-${e.access}" data-tier="${e.access}">
|
|
201
|
+
<h2 tabindex="-1">${wn(e.title,{sec:e.access})}</h2>
|
|
202
|
+
<p class="api-blurb muted">${be(e.blurb)}</p>
|
|
203
|
+
${e.resources.map(n=>Yd(e,n,t))}
|
|
204
|
+
</section>`}function Zd(e){let t=Co(e);if(!t.length)return null;let n=t.map(s=>Mo(s.status,xs(s.code),null,be(s.meaning)));return it("Refusals any route can give",o`<div class="api-ledger api-refusals">${n}</div>`)}function Jd(e){let t=Co(e).map(s=>s.code),n=s=>o`<div class="api-res" id="api-overview-${s.key}" data-sec="overview-${s.key}" data-hay="${[s.title,...s.key==="errors"?t:[]].join(" ").toLowerCase()}">
|
|
205
|
+
<div class="api-res-head"><h3 tabindex="-1">${wn(s.title,{sec:`overview-${s.key}`})}</h3></div>
|
|
206
|
+
${yn(s.text,"api-prose")}
|
|
207
|
+
${s.key==="errors"?Qd(e.statuses):null}
|
|
208
|
+
${s.fields?.length?it(s.key==="errors"?"The error body":"Fields",Io(s.fields)):null}
|
|
209
|
+
${s.key==="errors"?Zd(e):null}
|
|
210
|
+
</div>`;return o`<section class="api-section" id="api-overview" data-tier="overview">
|
|
211
|
+
${e.concepts.map(n)}
|
|
212
|
+
</section>`}function Qd(e){let t=e.map(n=>Mo(n.status,o`${n.name}`,null,be(n.meaning)));return it("Status codes",o`<div class="api-ledger api-statuses">${t}</div>`)}function Xd(e,t){let n=(i,a)=>o`<a href="#/api-reference/${i}" class="api-nav-res" data-sec="${i}">${a}</a>`,s=(i,a)=>o`<div class="api-nav-ops" data-of="${i.access}-${a.key}">${[...(t.heads.get(`${i.access}-${a.key}`)??[]).map(r=>o`<a href="#/api-reference/objects-${r.key}" class="api-nav-op" data-sec="objects-${r.key}">${r.title}</a>`),...Ea(i,a).map(r=>o`<a href="#/api-reference/${zt(r)}" class="api-nav-op" data-route="${zt(r)}">${r.title}</a>`)]}</div>`;return o`<a href="#/api-reference" class="api-nav-tier" data-sec="overview" data-tier="overview">Introduction</a>${e.concepts.map(i=>n(`overview-${i.key}`,i.title))}${e.groups.map(i=>o`<a href="#/api-reference/${i.access}" class="api-nav-tier" data-sec="${i.access}" data-tier="${i.access}">${i.title}</a>${i.resources.map(a=>[i.resources.length>1?n(`${i.access}-${a.key}`,a.title):null,s(i,a)])}`)}`}function eu(e,t){let n=e.replace(/^[a-z]+:\/\/[^/]*/,"").replace(/[?#].*$/,"");if(!n.startsWith("/"))return!1;let s=n.replace(/(.)\/+$/,"$1").split("/"),i=t.split("/");return s.length===i.length&&i.every((a,r)=>a.startsWith(":")?s[r]!=="":a===s[r])}function tu(e,t){let n=e.dataset.path;return(e.dataset.hay??"").includes(t)||n!==void 0&&eu(t,n)}function $a(e,t,n){let s=e.trim().toLowerCase().split(/\s+/).filter(Boolean);t.classList.toggle("filtering",s.length>0);let i=0;for(let l of I(".api-section",n)){let d=0;for(let m of I(".api-res",l)){let h=m.dataset.hay!==void 0,f=0;for(let w of h?[m]:I("li[data-hay]",m)){let E=s.every(A=>tu(w,A));w.hidden=!E,f+=E?1:0;let M=w.dataset.route?t.querySelector(`a[data-route="${w.dataset.route}"]`):null;M&&(M.hidden=!E)}m.hidden=f===0;for(let w of I(`.api-nav-ops[data-of="${m.dataset.sec}"] a[data-sec]`,t))w.hidden=s.length>0&&f===0;let g=t.querySelector(`a[data-sec="${m.dataset.sec}"]`);g&&(g.hidden=f===0),d+=f}l.hidden=d===0;let u=t.querySelector(`a[data-tier="${l.dataset.tier}"]`);u&&(u.hidden=d===0),i+=d}let a=I(".api-section",n),r=a.filter(l=>!l.hidden).at(-1);for(let l of a)l.classList.toggle("api-last",l===r);c("#apiEmpty",n).hidden=i>0}function Ho(e,t,n){let s=(c(".room-bar").getBoundingClientRect().height||54)+8,i=I(".api-section, .api-res, .api-object, li[data-route]",t).filter(w=>!w.closest("[hidden]")),a=window.scrollY>0&&Math.ceil(window.innerHeight+window.scrollY)>=document.documentElement.scrollHeight,r;for(let w of i)if(a||w.getBoundingClientRect().top<=s)r=w;else break;let l=I("a[data-sec], a[data-route]",e).filter(w=>!w.hidden&&getComputedStyle(w).display!=="none"),d=w=>w?(w.dataset.route?l.find(M=>M.dataset.route===w.dataset.route):l.find(M=>M.dataset.sec===(w.dataset.sec??w.dataset.tier)))??d(w.parentElement?.closest(".api-res, .api-section")):void 0,u=r?.closest(".api-res")?.dataset.sec;for(let w of I(".api-nav-ops",e))w.classList.toggle("open",w.dataset.of===u);let m=d(r??i[0]);for(let w of I("a[data-sec], a[data-route]",e))w.classList.toggle("active",w===m);let h=e.closest(".rail-inner");if(m&&h&&h.scrollHeight>h.clientHeight){let w=m.getBoundingClientRect(),E=h.getBoundingClientRect();(w.top<E.top+40||w.bottom>E.bottom-40)&&(h.scrollTop+=w.top-E.top-E.height/3)}let f=r?.dataset.route??r?.dataset.sec??r?.dataset.tier,g=f&&f!=="overview"&&window.scrollY>0?`#/api-reference/${f}`:"#/api-reference";n&&location.hash!==g&&history.replaceState(history.state,"",g)}function xa(e){return document.getElementById(e==="overview"?"api-intro":`api-${e}`)}function ka(e){e.id==="api-intro"?window.scrollTo(0,0):e.scrollIntoView({block:"start"}),e.querySelector(":scope > h1, :scope > h2, :scope > .api-res-head h3, :scope > .api-op-head h4, :scope > article > .api-op-head h4")?.focus({preventScroll:!0})}async function No(e,t,n){b(e,It("api-reference",o`<div class="toc-label">API</div><nav class="api-nav" id="apiNav" aria-label="API sections"></nav>`,o`<div class="api-content" id="apiContent"><p class="muted">Loading…</p></div>`));let s=c("#apiNav"),i=c("#apiContent"),a=g=>{let w=g.target instanceof Element?g.target.closest("a[data-sec], a[data-route]"):null,E=w?.dataset.route?document.getElementById(`route-${w.dataset.route}`):w?.dataset.sec?xa(w.dataset.sec):null;!E||g.button!==0||g.metaKey||g.ctrlKey||g.shiftKey||g.altKey||(g.preventDefault(),(E.closest("[hidden]")||w?.closest("#apiEmpty"))&&h(""),history.replaceState(history.state,"",w?.getAttribute("href")??location.hash),ka(E),Ho(s,i,!1))};s.addEventListener("click",a),i.addEventListener("click",a);let r;try{r=await H("/api/reference",{signal:t})}catch(g){ne(i,q(g),()=>Q((w,E)=>No(w,E,n)));return}let l={meanings:new Map(r.refusals.flatMap(g=>g.codes.map(w=>[w.code,w.meaning]))),objects:new Map(r.objects.map(g=>[g.key,g.title])),heads:qd(r)};b(s,Xd(r,l)),b(i,o`<header class="api-head" id="api-intro"><h1 tabindex="-1">API reference</h1><p class="muted">Every endpoint this instance serves, the one API the web editor and Claude both call. It's read from the running code, so it always matches this version. Base URL <code>${location.origin}</code>.</p><div class="api-filter">${N("search")}<input type="search" id="apiFilter" placeholder="Filter by name, path, URL, or error code" autocomplete="off" aria-label="Filter routes" aria-keyshortcuts="/"><kbd aria-hidden="true">/</kbd><button type="button" class="icon api-filter-clear" id="apiFilterClear" aria-label="Clear filter" hidden>${N("x")}</button></div></header>${Jd(r)}${r.groups.map(g=>zd(g,l))}<p class="api-empty muted" id="apiEmpty" hidden>Nothing matches. Try a path segment like <code>sends</code>, a method like <code>DELETE</code>, or a code like <code>stale_revision</code>, or start from the <a href="#/api-reference" data-sec="overview">Introduction</a>.</p>`);let d=c("#apiFilter"),u=c("#apiFilterClear"),m=()=>Ho(s,i,!1),h=g=>{d.value=g,u.hidden=g==="",$a(g,s,i),m()};d.addEventListener("input",()=>h(d.value)),u.addEventListener("click",()=>{h(""),d.focus()}),ot(i),document.addEventListener("keydown",g=>{let w=g.target instanceof HTMLElement&&(g.target.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(g.target.tagName));g.key==="/"&&!w&&!g.metaKey&&!g.ctrlKey&&!g.altKey?(g.preventDefault(),d.focus()):g.key==="Escape"&&g.target===d&&d.value&&h("")},{signal:t}),$a("",s,i),window.addEventListener("scroll",()=>Ho(s,i,!0),{passive:!0,signal:t}),window.scrollTo(0,0);let f=n?document.getElementById(`route-${n}`)??xa(n):null;f&&ka(f),m()}var ks=q;function nu(e){let t=[Ye(e,"reschedule")?o`<button class="ghost" data-reschedule="${e.id}">Edit schedule</button>`:null,Ye(e,"cancel")?o`<button class="ghost" data-cancel="${e.id}">Cancel</button>`:null];return t.some(Boolean)?o`<div class="row" data-closes="${e.fire_at}">${t}</div>`:null}async function Bo(e,t){let n={status:"sent",search:"",failures:"",sort:"fire_at",dir:"desc",offset:0,limit:50};b(e,o`<h1>Sent</h1>
|
|
213
|
+
${on()?o`<p class="muted">A local stand-in sends these: they are recorded here, but nothing is delivered.</p>`:null}
|
|
214
|
+
${Jn("sent-not-connected",o`No email provider connected, so nothing can be sent yet. ${He()} to start sending.`)}
|
|
203
215
|
<div id="stuck"></div>
|
|
204
216
|
<h2>Scheduled</h2><div id="scheduled"><p class="muted">Loading…</p></div>
|
|
205
217
|
<div id="active"></div>
|
|
206
218
|
<h2>Sent posts</h2>
|
|
207
|
-
${
|
|
219
|
+
${Ut({searchPlaceholder:"Search subject\u2026",failures:!0})}
|
|
208
220
|
<div id="sendsList"><p class="muted">Loading…</p></div>
|
|
209
|
-
<div id="sendsPager"></div>`);let s=c("#stuck",e),i=c("#scheduled",e),
|
|
221
|
+
<div id="sendsPager"></div>`);let s=c("#stuck",e),i=c("#scheduled",e),a=c("#active",e),r=c("#sendsList",e),l=c("#sendsPager",e),d=ps(e,t),u=[],m=!1,h=0,f=0,g=new Map;function w(){Z(),U(),Ue()}function E(v){let k=v.filter(S=>S.status==="sending"&&!un(S));b(a,k.length?o`<h2>In progress</h2>${k.map(S=>ms(S))}`:o``);for(let S of I("[data-watch]",a))S.onclick=C=>{C.target instanceof Element&&C.target.tagName==="A"||(location.hash=`#/sent/${S.dataset.watch}`)}}function M(v){let k=v.flatMap(K=>{let ie=fe(K,"refused");return ie?[{s:K,c:ie}]:[]}),S=ge()?k.filter(K=>K.c.cause==="not_connected"):[],C=k.filter(K=>!S.includes(K)),L=e.querySelector(".sent-not-connected");L&&(L.hidden=S.length>0);let J=S.map(({s:K},ie)=>o`${ie===0?"":ie===S.length-1?" and ":", "}<a href="#/sent/${K.id}">${K.subject}</a>`),D=S.length?o`<div class="card stuck-card sent-held"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div>${Zn(o`${J}`,S.length)}</div></div></div>`:null,se=v.flatMap(K=>{let ie=fe(K,"wedged");return ie?[{s:K,c:ie}]:[]});b(s,o`${D}${C.map(({s:K,c:ie})=>o`<div class="card stuck-card"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div><strong><a href="#/sent/${K.id}">${K.subject}</a></strong><div class="muted">${ie.message}</div></div></div></div>`)}${se.map(({s:K,c:ie})=>o`<div class="card stuck-card"><div class="stuck-head"><span class="stuck-dot">⚠️</span><div><strong><a href="#/sent/${K.id}">${K.subject}</a></strong><div class="muted">${ie.message}</div></div></div>${Ye(K,"resolve")?o`<button class="primary" data-resolve="${K.id}">Resolve…</button>`:null}</div>`)}`);for(let K of I("[data-resolve]",s)){let ie=se.find(at=>at.s.id===K.dataset.resolve);ie&&(K.onclick=()=>gs({id:ie.s.id,c_in_flight:ie.c.count},w,t))}}function A(){for(let v of g.values()){let k=I("tr[data-id]",r).find(S=>S.dataset.id===v.id)?.querySelector("td.delivered");k&&b(k,Ot(v.counts))}}function T(){let v=Bt(u,g);M(v),m&&E(v),A()}let $=v=>v==="scheduled"||v==="due",p=v=>v==="sent"||v==="complete";function x(v){for(let k of v.sends)g.set(k.id,k);for(let k of v.removed)g.delete(k);T(),(v.sends.some(k=>$(et(k)))||v.changes.some(k=>$(k.from)))&&Z(),v.removed.length?(Z(),U()):v.changes.some(k=>p(k.to)&&!p(k.from))&&U()}let R=!1;function F(v){let k=v.filter(S=>S!==null);R||!k.length||(R=!0,Nt({cursor:k.map(S=>S.cursor).reduce(ts),sends:k.flatMap(S=>S.sends)},{update:x,stale:()=>{t.aborted||Q(Bo)}},t))}async function V(){try{let v=await H("/api/sends?status=sending&limit=200",{signal:t});return u=v.sends,m=!0,T(),v}catch(v){return ne(a,ks(v),async()=>F([await V()])),null}}async function Z(){let v=++h;try{let k=await H("/api/sends?status=scheduled&sort=fire_at&dir=asc&limit=200",{signal:t});if(v!==h)return k;let{sends:S}=k,C=D=>o`<div class="card spread clickable sched-card" data-post="${D.post_id}"><div><a class="card-link sched-subj" href="#/edit/${D.post_id}">${D.subject}</a><div class="muted">${us(D)} · ${W(D.fire_at)} · ${D.audience.count} recipients${ds(D)}</div></div>${nu(D)}</div>`,[L,...J]=S;if(!L)b(i,o`<p class="muted">Nothing scheduled.</p>`);else{b(i,o`${C(L)}${J.length?o`<div id="schedMore" hidden>${J.map(C)}</div><button type="button" class="ghost sched-toggle" id="schedToggle" aria-expanded="false">Show all ${S.length} scheduled</button>`:null}`);let D=i.querySelector("#schedToggle");D&&(D.onclick=()=>{let se=c("#schedMore",i),K=se.hidden;se.hidden=!K,D.setAttribute("aria-expanded",String(K)),D.textContent=K?"Show fewer":`Show all ${S.length} scheduled`})}for(let D of I(".card.clickable",i))D.onclick=se=>{let K=se.target;K instanceof Element&&K.tagName!=="A"&&!K.closest("button")&&(location.hash=`#/edit/${D.dataset.post}`)};for(let D of I("[data-reschedule]",i)){let se=S.find(K=>K.id===D.dataset.reschedule);se&&(D.onclick=()=>{vs(se,w,t)})}for(let D of I("[data-cancel]",i))D.onclick=()=>G(D,"Canceling\u2026",async()=>{try{await H(`/api/sends/${D.dataset.cancel}/cancel`,{method:"POST"}),O("Canceled"),w()}catch(se){O(ks(se))}});return d(),k}catch(k){return v===h&&ne(i,ks(k),async()=>F([await Z()])),null}}async function U(){let v=++f;try{let k=await H(`/api/sends?${Ft(n)}`,{signal:t});if(v!==f)return k;let S=k.sends;if(!S.length)return b(r,o`<p class="muted">${n.search?"No sent posts match.":n.failures?"Every sent post delivered cleanly.":"No sent posts yet."}</p>`),b(l,o``),k;b(r,o`<div class="table-wrap"><table class="list-table sent-table stacks"><colgroup><col><col class="c-date"><col class="c-num"><col class="c-delivered"></colgroup><thead><tr>${Se("Subject","subject",n)}${Se("When","fire_at",n)}${Se("Recipients","audience.count",n,"num")}<th class="num">Delivered</th></tr></thead><tbody>${S.map(C=>o`<tr class="clickable" data-id="${C.id}"><td class="subject">${wt(o`<a href="#/sent/${C.id}">${C.subject}</a>`,C)}</td><td class="muted">${W(C.completed_at??C.fire_at)}</td><td class="num recipients"><span class="n">${C.audience.count.toLocaleString()}</span></td><td class="num delivered">${Ot(C.counts)}</td></tr>`)}</tbody></table></div>`),tt(r,n,U);for(let C of I("tr[data-id]",r))C.onclick=L=>{L.target instanceof Element&&L.target.tagName==="A"||(location.hash=`#/sent/${C.dataset.id}`)};return nt(l,n,k.page,U),A(),k}catch(k){return v===f&&ne(r,ks(k),async()=>F([await U()])),null}}qt(e,n,U),F(await Promise.all([Z(),V(),U()]))}var su="erased:";function _a(e){return e.startsWith(su)}function Et(e,t,n){let s=URL.createObjectURL(new Blob([e],{type:n})),i=document.createElement("a");i.href=s,i.download=t,i.click(),setTimeout(()=>URL.revokeObjectURL(s),1e4)}function Es(e){if(e.event==="delivered")return{label:"Delivered",sw:"ok"};if(e.event==="bounced")return e.bounce_kind==="hard"?{label:"Hard bounce",sw:"warn"}:e.bounce_kind==="soft"?{label:"Soft bounce",sw:"warn"}:{label:"Bounce",sw:"warn"};if(e.event==="complained")return{label:"Complained",sw:"danger"};switch(e.status){case"unsent":return{label:"Unsent",sw:"neutral"};case"skipped":return{label:"Skipped",sw:"neutral"};case"accepted":return{label:"Accepted",sw:"sending"};default:return{label:"In flight",sw:"sending"}}}var Sn=q;async function Oo(e,t,n){let s=()=>{n.aborted||Q((r,l)=>Oo(e,r,l))};b(t,o`<p class="muted">Loading…</p>`);let i;try{i=await H(`/api/sends/${e}`,{signal:n})}catch(r){ne(t,Sn(r),s);return}if(n.aborted)return;let{send:a}=i;if(a.status==="scheduled"){location.replace(`#/edit/${a.post_id}`);return}return a.status==="sending"?uu(i,t,n,s):gu(e,i,t,n,s)}var ou={scheduled:{label:"Scheduled",tone:"scheduled"},due:{label:"Due",tone:"scheduled"},progressing:{label:"Sending",tone:"sending"},retrying:{label:"Retrying",tone:"warn"},backing_off:{label:"Backing off",tone:"warn"},needs_attention:{label:"Needs attention",tone:"danger"},settling:{label:"Settling",tone:"sending"},complete:{label:"Complete",tone:"ok"},canceled:{label:"Canceled",tone:"muted"}},iu={progressing:"Handing recipients to the provider.",retrying:"Some recipients hit a transient error and will be retried.",backing_off:"Some recipients hit a transient error. They\u2019re retried on the next sweep.",needs_attention:"Some deliveries can\u2019t be confirmed either way, and wait on your decision.",settling:"Dispatch complete, waiting on delivery receipts."};function ru(e){let t=e.provider.halt;return t&&Ia(e)?`${t.cause==="not_connected"?"No email provider is connected":t.reason==="account"?"The provider is refusing this account":"The provider is unavailable"}, so this post waits with the send already held and hasn\u2019t handed anyone off; ${Dt(t.retry_at)}.`:Le(e,"refused")&&t?.cause==="not_connected"?`No email provider is connected, so nothing goes out until one is; ${Dt(t.retry_at)}.`:Le(e,"refused")&&t?`The provider is refusing ${no(t.cause)?"this send":"this account"}, so nothing more goes out until it\u2019s fixed; ${Dt(t.retry_at)}.`:e.phase==="backing_off"&&t?.reason==="unavailable"?`The provider is unavailable; ${Dt(t.retry_at)}.`:Le(e,"stuck")?"Nothing is being handed off to the provider right now.":e.phase==="progressing"&&e.counts.dispatched===0&&e.counts.pending>0?"Paused between ticks. It resumes on the next sweep.":iu[e.phase]||""}function $n(e){let t=ou[e]??{label:e,tone:"muted"};return o`<span class="phase-pill tone-${t.tone}">${t.label}</span>`}function au(e,t,n,s,i){let a=s>0?dn(100*n/s):0;return o`<div class="wbar-row">
|
|
210
222
|
<div class="wbar-head"><span class="wbar-label">${t}</span><span class="wbar-count">${n.toLocaleString()} / ${s.toLocaleString()}</span></div>
|
|
211
|
-
<div class="wbar" role="progressbar" aria-valuenow="${
|
|
223
|
+
<div class="wbar" role="progressbar" aria-valuenow="${a}" aria-valuemin="0" aria-valuemax="100"><div class="wbar-fill tone-${e}" style="width:${a}%"></div></div>
|
|
212
224
|
${i?o`<div class="wbar-sub muted">${i}</div>`:null}
|
|
213
|
-
</div>`}function
|
|
225
|
+
</div>`}function lu(e,t,n,s,i){let a=s>0?dn(100*n/s):0,r=s>0?dn(100*t/s):0;return o`<div class="wbar-row">
|
|
214
226
|
<div class="wbar-head"><span class="wbar-label">${e}</span><span class="wbar-count">${t.toLocaleString()} of ${n.toLocaleString()} accepted</span></div>
|
|
215
|
-
<div class="wbar dual" role="progressbar" aria-valuenow="${
|
|
216
|
-
<div class="wbar-fill tone-accepted" style="width:${
|
|
217
|
-
<div class="wbar-fill tone-ok" style="width:${
|
|
227
|
+
<div class="wbar dual" role="progressbar" aria-valuenow="${r}" aria-valuemin="0" aria-valuemax="100">
|
|
228
|
+
<div class="wbar-fill tone-accepted" style="width:${a}%"></div>
|
|
229
|
+
<div class="wbar-fill tone-ok" style="width:${r}%"></div>
|
|
218
230
|
</div>
|
|
219
231
|
<div class="wbar-sub muted">${i}</div>
|
|
220
|
-
</div>`}var
|
|
221
|
-
${
|
|
222
|
-
${
|
|
223
|
-
${d?o`<div class="health red" role="alert"><span class="health-dot">⚠️</span><div><strong>${u?"No email provider is connected":m?"The provider is refusing this send":"The provider is refusing this account"}</strong>${u?null:o`<div>${d.error}</div>`}<div class="refused-advice">${u?
|
|
232
|
+
</div>`}var cu=[{key:"pending",label:"Pending",sw:"neutral"},{key:"dispatched",label:"In flight",sw:"sending"},{key:"accepted",label:"Accepted",sw:"sending"},{key:"delivered",label:"Delivered",sw:"ok"},{key:"bounced",label:"Bounced",sw:"warn"},{key:"complained",label:"Complained",sw:"danger"},{key:"unsent",label:"Unsent",sw:"neutral"},{key:"skipped",label:"Skipped",sw:"neutral"}];function du(e){return o`<div class="wcounts">${cu.map(t=>o`<div class="wcount"><div class="wcount-n">${(e[t.key]||0).toLocaleString()}</div><div class="wcount-l"><span class="rec-sw sw-${t.sw}"></span>${t.label}</div></div>`)}</div>`}function Ma(e){let t=e.counts,n=t.accepted+t.delivered+t.bounced+t.complained,s=e.dispatch.rate_per_min,i=e.dispatch.eta_ms,a=e.status==="sending"?`${ru(e)}${i?`${s?` \xB7 ~${s.toLocaleString()}/min`:""} \xB7 ETA ${cs(i)}`:""}`:"Dispatch complete.",r=(t.unsent||0)+(t.bounced||0)+(t.complained||0),l=on()?"Local stand-in: nothing is delivered":ge()?"No email provider connected":`Provider: ${e.provider?.name||"unknown"}`,d=fe(e,"refused"),u=d?.cause==="not_connected",m=no(d?.cause??null),h=fe(e,"stuck"),f=fe(e,"provider_unavailable"),g=f?.severity==="warn"?f:void 0;return o`
|
|
233
|
+
${h?o`<div class="health amber" role="status"><span class="health-dot">⚠️</span><div>${h.message}</div></div>`:null}
|
|
234
|
+
${g?o`<div class="health amber" role="status"><span class="health-dot">⚠️</span><div>${g.message}</div></div>`:null}
|
|
235
|
+
${d?o`<div class="health red" role="alert"><span class="health-dot">⚠️</span><div><strong>${u?"No email provider is connected":m?"The provider is refusing this send":"The provider is refusing this account"}</strong>${u?null:o`<div>${d.error}</div>`}<div class="refused-advice">${u?io():d.advice}</div><div>${d.since===null?null:`Since ${W(d.since)}. `}No one has been marked unsent because of this, and once ${u?"one is connected":"this is fixed"} the send resumes at its next retry${d.retry_at===null?"":`, ${W(d.retry_at)}`}.</div></div></div>`:null}
|
|
224
236
|
<div class="wbars">
|
|
225
|
-
${
|
|
226
|
-
${
|
|
237
|
+
${au("sending","Dispatch: provider-accepted",n,e.audience.count,a)}
|
|
238
|
+
${lu("Delivery: webhook-confirmed",e.delivery.confirmed,n,e.audience.count,"Delivery lags acceptance: grey is accepted but unconfirmed, and green fills in as receipts arrive.")}
|
|
227
239
|
</div>
|
|
228
|
-
${
|
|
229
|
-
<div class="whealth${
|
|
240
|
+
${du(t)}
|
|
241
|
+
<div class="whealth${r?" has-failures":""}"><span class="whealth-dot"></span><span>${l} · ${r?`${r.toLocaleString()} bounced / unsent / complained`:"no delivery failures"}</span></div>`}function Ia(e){return e.status==="sending"&&e.provider.halt!==null&&!e.audience.fixed}function Na(e){if(Ia(e))return o`Due ${W(e.fire_at)} · held before handing anyone off · about ${e.audience.count.toLocaleString()} recipients`;let t=e.started_at?`Started ${W(e.started_at)}`:"Sending now";return o`${t} · ${e.audience.count.toLocaleString()} recipients`}function Ta(e){let t=e;return o`
|
|
230
242
|
<div class="editor-head">
|
|
231
243
|
<a href="#/sent" class="back">← Sent</a>
|
|
232
|
-
${
|
|
244
|
+
${Ye(t,"resolve")?o`<button type="button" class="primary" id="resolveBtn">Resolve…</button>`:null}
|
|
233
245
|
</div>
|
|
234
246
|
<div class="card rec-card watch-card">
|
|
235
247
|
<div class="rec-head">
|
|
236
|
-
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="watchPill">${
|
|
237
|
-
<div class="rec-meta" id="watchMeta">${
|
|
248
|
+
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="watchPill">${$n(t.phase)}</span></div>
|
|
249
|
+
<div class="rec-meta" id="watchMeta">${Na(e)}</div>
|
|
238
250
|
</div>
|
|
239
|
-
<div id="watchBody">${
|
|
251
|
+
<div id="watchBody">${Ma(t)}</div>
|
|
240
252
|
<p class="rec-note muted">This view updates live while the send is in flight. Delivery receipts keep arriving after dispatch finishes, and the record stays accurate as they settle.</p>
|
|
241
|
-
</div>`}function
|
|
253
|
+
</div>`}function La(e,t,n,s){let i=e.querySelector("#resolveBtn");if(i){let a=fe(t,"wedged")?.count??t.counts.dispatched;i.onclick=()=>gs({id:t.id,c_in_flight:a},n,s)}}function Ra(e,t){let n=e.querySelector("#watchPill");n&&b(n,$n(t.phase));let s=e.querySelector("#watchMeta");s&&b(s,Na(t));let i=e.querySelector("#watchBody");i&&b(i,Ma(t))}function uu(e,t,n,s){b(t,Ta(e.send)),La(t,e.send,s,n);let i=e.send;pt(1e3,()=>Ra(t,i),n),ln(e,{update:a=>{if(a.status!=="sending"){s();return}i=a,Ye(a,"resolve")!==!!t.querySelector("#resolveBtn")?(b(t,Ta(a)),La(t,a,s,n)):Ra(t,a)},removed:s,stale:s},n)}function Ca(e){let t=[{n:e.recipients,label:"Recipients",cls:"",sw:"neutral"},{n:e.delivered,label:"Delivered",cls:"ok",sw:"ok"},{n:e.bounced,label:"Bounced",cls:"warn",sw:"warn"},{n:e.complained,label:"Complained",cls:"danger",sw:"danger"},{n:e.unsent,label:"Unsent",cls:"",sw:"neutral"}];return o`${t.map(n=>o`<div class="rec-tile"><div class="rec-n ${n.cls}">${n.n.toLocaleString()}</div><div class="rec-l"><span class="rec-sw sw-${n.sw}"></span>${n.label}</div></div>`)}`}function Ha(e){let t=e.recipients,n=[`${e.delivered.toLocaleString()} delivered`];e.bounced&&n.push(`${e.bounced.toLocaleString()} bounced`),e.complained&&n.push(`${e.complained.toLocaleString()} complained`),e.unsent&&n.push(`${e.unsent.toLocaleString()} unsent`),e.accepted&&n.push(`${e.accepted.toLocaleString()} accepted, awaiting a delivery receipt`),e.skipped&&n.push(`${e.skipped.toLocaleString()} skipped (unsubscribed or suppressed before hand-off, never mailed)`),e.pending&&n.push(`${e.pending.toLocaleString()} pending`),e.dispatched&&n.push(`${e.dispatched.toLocaleString()} in flight`);let s=`All ${t.toLocaleString()} accounted for: ${n.join(", ")}.`,i=e.bounced||e.complained?" Hard bounces are suppressed automatically and spam complaints unsubscribe the reader, so those addresses won\u2019t be mailed again.":"",a=e.bounced?" A soft bounce is temporary, so it\u2019s counted and the address stays on the list.":"";return`${s}${i}${a}`}function pu(e,t){return Object.keys(e).every(n=>e[n]===t[n])}var mu=[{v:"all",label:"All"},{v:"delivered",label:"Delivered"},{v:"failures",label:"Failures"}];function fu(e){return e.search?"No recipients match that address.":e.view==="failures"?"No delivery failures. Every recipient was accepted or delivered.":e.view==="delivered"?"No delivery receipts confirmed yet.":"No recipients on this send."}function hu(e){let t=new URLSearchParams;t.set("view",e.view);let n=(e.search||"").trim();return n&&t.set("search",n),e.sort&&(t.set("sort",e.sort),t.set("dir",e.dir??"asc")),t.set("limit",String(e.limit)),t.set("offset",String(e.offset)),t.toString()}function bu(e,t){let n=e.map(s=>{let i=Es(s),a=o`<span class="rec-out"><span class="rec-sw sw-${i.sw}"></span>${i.label}</span>`,r=s.error||s.event_detail||"",l=s.event_at?W(s.event_at):"",d=_a(s.email)?o`<span class="muted">Erased recipient</span>`:s.email;return o`<tr><td class="rec-email">${d}</td><td>${a}</td><td class="muted">${r||"\u2014"}</td><td class="muted">${l||"\u2014"}</td></tr>`});return o`<div class="table-wrap"><table class="list-table rec-people-table"><colgroup><col><col class="c-out"><col><col class="c-date"></colgroup><thead><tr>${Se("Recipient","email",t)}<th class="c-out">Outcome</th><th>Detail</th><th class="c-date">When</th></tr></thead><tbody>${n}</tbody></table></div>`}function gu(e,t,n,s,i){let{send:a,outcomes:r}=t;if(a.status==="canceled"){yu(a,n);return}let l=r.recipients,d=a.completed_at??a.fire_at;b(n,o`
|
|
242
254
|
<div class="editor-head">
|
|
243
255
|
<a href="#/sent" class="back">← Sent</a>
|
|
244
256
|
</div>
|
|
245
257
|
<div class="rec-head rec-head-page">
|
|
246
|
-
<div class="watch-title"><h1>${
|
|
247
|
-
<div class="rec-meta">Sent ${
|
|
258
|
+
<div class="watch-title"><h1>${a.subject||o`<em>untitled</em>`}</h1><span id="recPill">${$n(a.phase)}</span></div>
|
|
259
|
+
<div class="rec-meta">Sent ${W(d)} · ${l.toLocaleString()} recipients</div>
|
|
248
260
|
</div>
|
|
249
261
|
<section class="card rec-post" aria-labelledby="recPostTitle">
|
|
250
262
|
<h2 class="rec-part-title" id="recPostTitle">The post</h2>
|
|
251
|
-
<div class="rec-web" id="recWeb">${
|
|
263
|
+
<div class="rec-web" id="recWeb">${Aa(a)}</div>
|
|
252
264
|
</section>
|
|
253
265
|
<section class="card rec-card" aria-labelledby="recDeliveryTitle">
|
|
254
266
|
<h2 class="rec-part-title" id="recDeliveryTitle">Delivery</h2>
|
|
255
|
-
<div id="recAlert">${
|
|
267
|
+
<div id="recAlert">${Pa(a)}</div>
|
|
256
268
|
<p class="rec-tiles-cap muted">Delivery outcomes keep updating as receipts arrive; the audience at fire and the published post are fixed.</p>
|
|
257
|
-
<div class="rec-tiles">${
|
|
258
|
-
<p class="rec-recon muted">${
|
|
269
|
+
<div class="rec-tiles">${Ca(r)}</div>
|
|
270
|
+
<p class="rec-recon muted">${Ha(r)}</p>
|
|
259
271
|
<div class="rec-actions">
|
|
260
272
|
<button type="button" class="ghost" id="csvBtn">Export recipients (CSV)</button>
|
|
261
273
|
</div>
|
|
@@ -263,7 +275,7 @@
|
|
|
263
275
|
<div class="rec-people-head">
|
|
264
276
|
<h3 class="rec-people-title">Recipients</h3>
|
|
265
277
|
<div class="rec-views" role="group" aria-label="Which recipients to show">
|
|
266
|
-
${
|
|
278
|
+
${mu.map((v,k)=>o`<button type="button" class="rec-view-btn" data-view="${v.v}" aria-pressed="${k===0?"true":"false"}">${v.label}</button>`)}
|
|
267
279
|
</div>
|
|
268
280
|
</div>
|
|
269
281
|
<input class="rec-people-search" type="search" placeholder="Search email…" aria-label="Search recipients by email" autocomplete="off">
|
|
@@ -271,37 +283,37 @@
|
|
|
271
283
|
<div id="recPager"></div>
|
|
272
284
|
</div>
|
|
273
285
|
<p class="rec-note muted">This is the record of what went out: the published post is the exact frozen copy readers received, and nothing here is editable.</p>
|
|
274
|
-
</section>`);let u=c("#recWeb",n),m=
|
|
275
|
-
${
|
|
276
|
-
${
|
|
286
|
+
</section>`);let u=c("#recWeb",n),m=a,h=()=>{b(u,Aa(m));let v=u.querySelector("#recOpen"),k=u.querySelector("#recCopy"),S=u.querySelector("#recListing"),C=Ba(m);v&&C&&(v.onclick=()=>window.open(C,"_blank","noopener")),k&&C&&(k.onclick=()=>{hn(k,C)}),S&&(S.onclick=()=>m.email_only?w():g())},f=(v,k,S)=>G(v,k.email_only?"Updating\u2026":"Adding\u2026",async()=>{S.clearError();try{let C=await H(`/api/posts/${a.post_id}/settings`,{method:"PUT",json:k});S.close();let L=C.post.archive_live?C.post.archive_url:null;m={...m,email_only:C.post.email_only,links:{...m.links,archive:L}},h(),O(C.post.email_only?"Removed from your archive":`Added to your archive at ${L?bn(L):C.post.slug}`),Ue()}catch(C){S.error(Sn(C))}}),g=()=>{let v=m.links.archive,k=m.web_link==="archive",S=ue(o`<h3 id="eoHead">Remove this post from your archive?</h3>
|
|
287
|
+
${k?o`<p class="hint">It comes off your home page and your public archive. Its emails link to its archive address, so that address keeps working for anyone who has them, and isn’t listed or indexed.</p>`:o`<p class="hint">It comes off your home page and your public archive, and its archive address stops working, including links people have shared. The link in the email keeps working for anyone who has it.</p>
|
|
288
|
+
${v?o`<div class="url-gone"><s>${bn(v)}</s></div>`:null}
|
|
277
289
|
<p class="pub-note">The address stays this post’s. Add it back and the same address, and the links people shared, work again.</p>`}
|
|
278
|
-
<div class="actions"><button type="button" id="eoCancel">Cancel</button><button type="button" class="primary" id="eoGo">Remove from archive</button></div>`,s);c(".modal",
|
|
290
|
+
<div class="actions"><button type="button" id="eoCancel">Cancel</button><button type="button" class="primary" id="eoGo">Remove from archive</button></div>`,s);c(".modal",S.el).setAttribute("aria-labelledby","eoHead"),c("#eoCancel",S.el).onclick=S.close;let C=c("#eoGo",S.el);C.onclick=()=>f(C,{email_only:!0},S),C.focus()},w=async()=>{let v;try{v=await H(`/api/posts/${a.post_id}`,{signal:s})}catch(J){s.aborted||O(Sn(J));return}let S={...Ze(v.post,v.subject_slug_held),emailOnly:!1},C=ue(o`<h3 id="addArchHead">Add this post to your archive?</h3>
|
|
279
291
|
<p class="hint">It will be listed on your home page and in your public archive, where anyone visiting can find it. The link in the email keeps working, and forwards to it.</p>
|
|
280
292
|
<div class="dialog-settings" id="addForm"></div>
|
|
281
|
-
<div class="actions"><button type="button" id="addArchCancel">Cancel</button><button type="button" class="primary" id="addArchGo">Add to archive</button></div>`,s);c(".modal",
|
|
293
|
+
<div class="actions"><button type="button" id="addArchCancel">Cancel</button><button type="button" class="primary" id="addArchGo">Add to archive</button></div>`,s);c(".modal",C.el).setAttribute("aria-labelledby","addArchHead"),c("#addArchCancel",C.el).onclick=C.close,$t(c("#addForm",C.el),S,"add",{include:!1,fixedNote:"Its address since it was sent: links people shared work again."});let L=c("#addArchGo",C.el);L.onclick=()=>f(L,{email_only:!1},C),L.focus()};h();let E={view:"all",search:"",sort:"email",dir:"asc",offset:0,limit:50},M=c("#recRows",n),A=c("#recPager",n),T=0,$=!1,p=async v=>{let k=++T;v||($=!0);try{let S=await H(`/api/sends/${e}/deliveries?${hu(E)}`,{signal:s});if(k!==T)return;let C=S.page.total>0?Math.floor((S.page.total-1)/S.page.limit)*S.page.limit:0;if(!S.deliveries.length&&E.offset>C)return E.offset=C,p(v);$=!1,R(()=>{if(!S.deliveries.length){b(M,o`<p class="rec-people-empty muted">${fu(E)}</p>`),b(A,o``);return}b(M,bu(S.deliveries,E)),tt(M,E,x),nt(A,E,S.page,x)})}catch(S){k===T&&(!v||$)&&($=!1,ne(M,Sn(S),x))}},x=()=>p(!1),R=v=>{let k=document.activeElement,S=k instanceof HTMLElement&&(M.contains(k)||A.contains(k)),C=S?k.dataset.sort:void 0,L=S?["pager-prev","pager-next"].find(D=>k.classList.contains(D)):void 0;v();let J=C?M.querySelector(`.th-sort[data-sort="${C}"]`):L?A.querySelector(`.${L}`):null;J&&!J.disabled&&J.focus()},F=I(".rec-view-btn",n);for(let v of F)v.onclick=()=>{let k=v.dataset.view;(k==="failures"||k==="delivered"||k==="all")&&(E.view=k),E.offset=0;for(let S of F)S.setAttribute("aria-pressed",String(S===v));x()};let V=n.querySelector(".rec-people-search");if(V){let v;V.oninput=()=>{clearTimeout(v),v=setTimeout(()=>{E.search=V.value,E.offset=0,x()},250)}}x();let Z=c("#csvBtn");Z.onclick=()=>G(Z,"Exporting\u2026",async()=>{try{let v=await jn(a.links.deliveries_csv);Et(v.text,v.filename??"deliveries.csv","text/csv")}catch(v){O(v instanceof ee&&v.status===404?"This send no longer exists, so there are no deliveries to export.":Sn(v))}});let U=r;ln(t,{update(v){if(v.status==="sending"){i();return}b(c("#recPill",n),$n(v.phase)),b(c("#recAlert",n),Pa(v)),(v.email_only!==m.email_only||v.links.archive!==m.links.archive||v.links.unlisted!==m.links.unlisted)&&(m=v,h());let k=vu(v);pu(U,k)||(U=k,b(c(".rec-tiles",n),Ca(k)),c(".rec-recon",n).textContent=Ha(k),p(!0))},removed:i,stale:i},s)}function Ba(e){return e.email_only&&e.web_link==="unlisted"?e.links.unlisted:e.links.archive??e.links.unlisted}function Aa(e){let t=Ba(e),n=t?bn(t):null,s=e.web_link==="archive",i=e.email_only?s?"Its emails link here. Anyone with the link can open it; nothing lists or indexes it.":"Anyone with the link can open it. Nothing lists or indexes it.":s?"Its emails link here.":"The link in the email forwards here.",a=t&&n?o`<p class="rec-link-label">Link</p><div class="rec-link"><code title="${n}">${n}</code><button type="button" class="copy-btn" id="recCopy" data-copy-word="Copy" aria-label="Copy link">${Re(!1,"Copy")}</button><button type="button" class="rec-open" id="recOpen">Open ↗</button></div><p class="rec-link-note muted">${i}</p>`:null,r=e.email_only?o`<div class="rec-arch-what"><strong>${N("mail")}Email only</strong><span class="rec-arch-note">Not on your home page or in your public archive.</span></div><button type="button" class="secondary" id="recListing">Add to archive…</button>`:o`<div class="rec-arch-what"><strong>${N("link")}In your archive</strong><span class="rec-arch-note">Listed on your home page and in your public archive.</span></div><button type="button" class="secondary" id="recListing">Remove from archive…</button>`;return o`${a}<div class="rec-arch">${r}</div>`}function Pa(e){let t=fe(e,"no_receipts");return t?o`<div class="health amber" role="status"><span class="health-dot">⚠️</span><div>${t.message}</div></div>`:o``}function vu(e){let t=e.counts;return{recipients:e.audience.count,delivered:t.delivered,bounced:t.bounced,complained:t.complained,unsent:t.unsent,skipped:t.skipped,accepted:t.accepted,pending:t.pending,dispatched:t.dispatched}}function yu(e,t){b(t,o`
|
|
282
294
|
<div class="editor-head">
|
|
283
295
|
<a href="#/sent" class="back">← Sent</a>
|
|
284
296
|
</div>
|
|
285
297
|
<div class="card rec-card">
|
|
286
298
|
<div class="rec-head">
|
|
287
|
-
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="recPill">${
|
|
288
|
-
<div class="rec-meta">Canceled ${
|
|
299
|
+
<div class="watch-title"><h1>${e.subject||o`<em>untitled</em>`}</h1><span id="recPill">${$n(e.phase)}</span></div>
|
|
300
|
+
<div class="rec-meta">Canceled ${W(e.completed_at)} · was scheduled for ${W(e.fire_at)}</div>
|
|
289
301
|
</div>
|
|
290
302
|
<p class="rec-note muted">This send was canceled before it fired, so no one was mailed.</p>
|
|
291
|
-
</div>`)}function
|
|
303
|
+
</div>`)}function Do(e){let t=document.getElementById(e);if(!t)throw new Error(`the admin page has no #${e}`);return t}var _s=(()=>{let e=Do("savebar"),t=e.querySelector(".savebar-msg"),n=Do("savebarSave"),s=Do("savebarDiscard"),i=0,a=u=>{t&&b(t,o`<span class="savebar-dot"></span><span>${u}</span>`)},r=()=>{e.hidden=!1,e.offsetHeight,e.classList.add("show")};function l(){i++,e.classList.remove("show","is-error"),e.hidden=!0,n.onclick=null,s.onclick=null,document.body.classList.remove("has-savebar")}function d({onSave:u,onDiscard:m,saveLabel:h="Save changes",discardLabel:f="Discard"},g){if(g.aborted)return{setDirty(){},showError(){}};let w=++i;g.addEventListener("abort",()=>{i===w&&l()},{once:!0}),n.textContent=h,s.textContent=f,e.classList.remove("show","is-error"),a("You have unsaved changes."),e.hidden=!0,document.body.classList.add("has-savebar"),n.onclick=()=>G(n,"Saving\u2026",()=>Promise.resolve(u())),s.onclick=()=>m();let E=()=>i===w;return{setDirty(M){E()&&(e.classList.remove("is-error"),M?(a("You have unsaved changes."),r()):e.classList.remove("show"))},showError(M){E()&&(e.classList.add("is-error"),a(M),r())}}}return{attach:d}})();var rt=q,xn=o` hidden`;function ve(e,t){return o`<span class="set-chip ${e}">${N(e)}${t}</span>`}function ye(e,t,n=null){return o`<div class="set-sec-head"><h2 class="set-sec-title">${e}</h2>${t}${n}<span class="set-rule"></span></div>`}function kn(e,t){return(String(e||t).trim()[0]||"K").toUpperCase()}function Oa(e){let{state:t,fromName:n}=e;return o`
|
|
292
304
|
<section class="set-sec">
|
|
293
|
-
${
|
|
305
|
+
${ye("Confirmation email",ve("editable","Editable"))}
|
|
294
306
|
<p class="set-lede">The email that asks a new subscriber to confirm. Edit the wording as you see fit. Kestrel adds the confirmation link.</p>
|
|
295
307
|
<div class="set-preview">
|
|
296
308
|
<div class="set-preview-bar set-ce-bar">
|
|
297
309
|
<div class="set-ce-modetog" role="group" aria-label="Confirmation email view">
|
|
298
|
-
<button type="button" class="set-ce-modebtn" id="ceTabPreview" aria-pressed="true">${
|
|
299
|
-
<button type="button" class="set-ce-modebtn" id="ceTabEdit" aria-pressed="false">${
|
|
310
|
+
<button type="button" class="set-ce-modebtn" id="ceTabPreview" aria-pressed="true">${N("preview")}Preview</button>
|
|
311
|
+
<button type="button" class="set-ce-modebtn" id="ceTabEdit" aria-pressed="false">${N("editable")}Edit</button>
|
|
300
312
|
</div>
|
|
301
313
|
</div>
|
|
302
314
|
<div id="cePreviewBody">
|
|
303
315
|
<div class="set-inbox">
|
|
304
|
-
<div class="set-inbox-avatar" id="cePvAvatar">${
|
|
316
|
+
<div class="set-inbox-avatar" id="cePvAvatar">${kn(t.name,n)}</div>
|
|
305
317
|
<div class="set-inbox-body">
|
|
306
318
|
<div class="set-inbox-top"><span class="set-inbox-from" id="cePvFrom">${t.name||n}</span><span class="set-inbox-time">now</span></div>
|
|
307
319
|
<div class="set-inbox-subj" id="cePvSubject"></div>
|
|
@@ -335,7 +347,7 @@
|
|
|
335
347
|
</div>
|
|
336
348
|
<div class="set-field">
|
|
337
349
|
<label for="ceButton">Button label</label>
|
|
338
|
-
<input id="ceButton" value="${t.confirmation.
|
|
350
|
+
<input id="ceButton" value="${t.confirmation.button_label}" maxlength="80" autocomplete="off">
|
|
339
351
|
</div>
|
|
340
352
|
<div class="set-field">
|
|
341
353
|
<label for="ceFooter">Reassurance line</label>
|
|
@@ -350,69 +362,69 @@
|
|
|
350
362
|
</div>
|
|
351
363
|
</div>
|
|
352
364
|
</div>
|
|
353
|
-
</section>`}function
|
|
365
|
+
</section>`}function Da(e,t){let{state:n}=t,s=t.data.settings.confirmation_email_default,i={subject:c("#ceSubject",e),message:c("#ceMessage",e),button:c("#ceButton",e),footer:c("#ceFooter",e)},a={avatar:c("#cePvAvatar",e),from:c("#cePvFrom",e),subject:c("#cePvSubject",e),body:c("#cePvBody",e),button:c("#cePvButton",e),foot:c("#cePvFoot",e),mast:c("#cePvMast",e),mastLogo:c("#cePvMastLogo",e),mastName:c("#cePvMastName",e),mastTag:c("#cePvMastTag",e)},r=()=>{let g=n.confirmation;a.avatar.textContent=kn(n.name,t.fromName),a.from.textContent=n.name||t.fromName,a.subject.textContent=g.subject.trim()||s.subject||"",a.body.textContent=g.body.trim()||s.body||"",a.button.textContent=g.button_label.trim()||s.button_label||"Confirm",a.foot.textContent=g.reassurance,a.foot.hidden=!g.reassurance.trim();let w=n.name||t.fromDisplay,E=!!n.logoUrl,M=E||!!w;a.mast.hidden=!M,M&&(a.mastLogo.hidden=!E,E&&b(a.mastLogo,o`<img src="${n.logoUrl}" alt="">`),a.mastName.textContent=w,a.mastName.hidden=!w,a.mastTag.textContent=n.tagline,a.mastTag.hidden=!n.tagline)},l=()=>{n.confirmation.subject=i.subject.value,n.confirmation.body=i.message.value,n.confirmation.button_label=i.button.value,n.confirmation.reassurance=i.footer.value,t.changed()};for(let g of Object.values(i))g.addEventListener("input",l);let d=c("#ceEditBody",e),u=c("#cePreviewBody",e),m=c("#ceTabEdit",e),h=c("#ceTabPreview",e),f=g=>{d.hidden=!g,u.hidden=g,m.setAttribute("aria-pressed",String(g)),h.setAttribute("aria-pressed",String(!g)),g&&i.subject.focus()};return h.onclick=()=>f(!1),m.onclick=()=>f(!0),c("#ceToPreview",e).onclick=()=>f(!1),c("#ceReset",e).onclick=()=>{i.subject.value=s.subject||"",i.message.value=s.body||"",i.button.value=s.button_label||"",i.footer.value=s.reassurance||"",l()},r(),{repaint:r,reset(){i.subject.value=n.confirmation.subject,i.message.value=n.confirmation.body,i.button.value=n.confirmation.button_label,i.footer.value=n.confirmation.reassurance,r()}}}var wu={none:"No email provider connected",fake:"Fake (dev, dead-end)",ses:"Amazon SES"};function Ts(e){return wu[e]||e}function Su(e){return e.provider==="none"?o`${Ts(e.provider)}. ${He("set-link")} to start sending.`:o`${Ts(e.provider)}${e.simulation?o`<span class="set-pill">Simulating ${$u[e.simulation.profile]}${e.simulation.faults==="none"?", no failures":""}</span>`:null}`}var $u={generic:"a generic provider",ses:"Amazon SES"},xu="#/docs/ses/3-receive-replies-at-your-from-address";function ku(e){let t=String(e||"").match(/<([^>]+)>/);return t?.[1]?t[1]:String(e||"")}function Fa(e){let t=e.data.deployment,{fromName:n}=e;return o`
|
|
354
366
|
<section class="set-sec">
|
|
355
|
-
${
|
|
367
|
+
${ye("Email sender",ve("readonly","Read-only \xB7 set at deploy"))}
|
|
356
368
|
<div class="set-card sunken">
|
|
357
|
-
<div class="set-ro-note">${
|
|
369
|
+
<div class="set-ro-note">${N("readonly")}<span>The sender is set at deploy in <code>wrangler.jsonc</code>, with the AWS keys stored as secrets, so it can’t be edited here. Change it in the <a class="set-link" href="#/docs/self-hosting">setup guide</a>, then redeploy. Credentials are never shown.</span></div>
|
|
358
370
|
<div class="set-preview-bar" style="background:transparent">
|
|
359
371
|
<span class="set-preview-lbl">Inbox preview</span>
|
|
360
372
|
<span class="set-preview-dot">How readers see the sender</span>
|
|
361
373
|
</div>
|
|
362
374
|
<div class="set-inbox">
|
|
363
|
-
<div class="set-inbox-avatar">${
|
|
375
|
+
<div class="set-inbox-avatar">${kn(n,n)}</div>
|
|
364
376
|
<div class="set-inbox-body">
|
|
365
377
|
<div class="set-inbox-top"><span class="set-inbox-from">${n}</span><span class="set-inbox-time">9:02 AM</span></div>
|
|
366
378
|
<div class="set-inbox-subj">Your latest post: a sample subject line</div>
|
|
367
379
|
<div class="set-inbox-snip">The opening lines of your post show here as the inbox preview…</div>
|
|
368
|
-
<div class="set-inbox-addr">${
|
|
380
|
+
<div class="set-inbox-addr">${ku(t.from_address)}</div>
|
|
369
381
|
</div>
|
|
370
382
|
</div>
|
|
371
383
|
<div class="set-kv" style="border-top:1px solid var(--line)">
|
|
372
|
-
<div class="set-kv-k">From address</div><div class="set-kv-v set-kv-stack"><span class="mono">${t.
|
|
373
|
-
<div class="set-kv-k">Sending domain</div><div class="set-kv-v"><span class="mono">${t.
|
|
374
|
-
<div class="set-kv-k">Email provider</div><div class="set-kv-v" id="providerValue">${
|
|
384
|
+
<div class="set-kv-k">From address</div><div class="set-kv-v set-kv-stack"><span class="mono">${t.from_address}</span><span class="muted" id="replyNote">Readers’ replies go to this address. <a class="set-link" href="${xu}">Forward it to your inbox</a>.</span></div>
|
|
385
|
+
<div class="set-kv-k">Sending domain</div><div class="set-kv-v"><span class="mono">${t.sending_domain}</span></div>
|
|
386
|
+
<div class="set-kv-k">Email provider</div><div class="set-kv-v" id="providerValue">${Su(t)}</div>
|
|
375
387
|
</div>
|
|
376
388
|
</div>
|
|
377
|
-
</section>`}function
|
|
389
|
+
</section>`}function Ua(e){let t=e.data.deployment,n=`${t.archive_origin||""}${t.archive_base_path||""}`,s=t.archive_origin===t.app_origin;return o`
|
|
378
390
|
<section class="set-sec">
|
|
379
|
-
${
|
|
391
|
+
${ye("Instance",ve("readonly","Read-only \xB7 set at deploy"))}
|
|
380
392
|
<div class="set-card sunken">
|
|
381
|
-
<div class="set-ro-note">${
|
|
393
|
+
<div class="set-ro-note">${N("info")}<span>Deploy-time infrastructure, shown for reference. These live in your Worker config and never pass through the API. See the <a class="set-link" href="#/docs/configuration">setup guide</a> to change them.</span></div>
|
|
382
394
|
<div class="set-kv">
|
|
383
|
-
<div class="set-kv-k">App origin</div><div class="set-kv-v"><span class="mono">${t.
|
|
395
|
+
<div class="set-kv-k">App origin</div><div class="set-kv-v"><span class="mono">${t.app_origin}</span></div>
|
|
384
396
|
<div class="set-kv-k">Archive URL base</div><div class="set-kv-v"><span class="mono">${n}</span>${s?o`<span class="set-pill">default: app origin</span>`:null}</div>
|
|
385
|
-
<div class="set-kv-k">Image URL base</div><div class="set-kv-v"><span class="mono">${t.
|
|
386
|
-
<div class="set-kv-k">Auth mode</div><div class="set-kv-v">${t.
|
|
387
|
-
<div class="set-kv-k">Cloudflare Access</div><div class="set-kv-v">${t.
|
|
388
|
-
<div class="set-kv-k">Minimum lead</div><div class="set-kv-v">${
|
|
397
|
+
<div class="set-kv-k">Image URL base</div><div class="set-kv-v"><span class="mono">${t.media_public_base}</span></div>
|
|
398
|
+
<div class="set-kv-k">Auth mode</div><div class="set-kv-v">${t.auth_mode==="access"?"Cloudflare Access":"Local dev token"}</div>
|
|
399
|
+
<div class="set-kv-k">Cloudflare Access</div><div class="set-kv-v">${t.access_configured?o`<span class="set-pill ok">${N("check")}Configured</span>`:o`<span class="set-pill">Not configured</span>`}</div>
|
|
400
|
+
<div class="set-kv-k">Minimum lead</div><div class="set-kv-v">${Pt(t.min_lead_ms)}${t.min_lead_ms===Vn?o`<span class="set-pill">default</span>`:null}</div>
|
|
389
401
|
</div>
|
|
390
402
|
</div>
|
|
391
|
-
</section>`}function
|
|
403
|
+
</section>`}function Eu(e){return e instanceof ee&&e.status===409&&e.data?.error==="remake_required"?e.data:null}var _u="Not saved: the change needed your confirmation";async function _t(e,t,n){let s=null;for(;;)try{return await e(s)}catch(i){let a=Eu(i);if(!a)throw i;let r=a.sends||[];if(!await Tu(r,t,n))return n.aborted&&O(_u),null;s=r.map(d=>d.id)}}function Tu(e,t,n){let s=e.length,i=s===1,a=`${s} scheduled email${i?"":"s"}`;return new Promise(r=>{let l=ue(o`<h3>Apply this change to ${a}?</h3>
|
|
392
404
|
<p class="hint">${t.lead(s)} ${t.gerund} applies the change to ${i?"its email":"their emails"} too.</p>
|
|
393
|
-
<ul class="remake-list">${e.map(
|
|
405
|
+
<ul class="remake-list">${e.map(g=>o`<li><span>${g.subject||o`<em>untitled</em>`}</span><span>sends ${W(g.fire_at)}</span></li>`)}</ul>
|
|
394
406
|
<dl class="remake-facts">
|
|
395
407
|
<div><dt>Changes</dt><dd>the look of ${i?"its email":"their emails"}</dd></div>
|
|
396
408
|
<div><dt>Stays</dt><dd>${i?"its content and fire time":"their content and fire times"}</dd></div>
|
|
397
409
|
<div><dt>Next</dt><dd>send yourself a fresh test${i?"":" of each"}</dd></div>
|
|
398
410
|
</dl>
|
|
399
411
|
<p class="hint">To leave scheduled emails as they are while future posts change, you’d need a second template, which Kestrel doesn’t offer yet.</p>
|
|
400
|
-
<div class="actions"><button type="button" id="rmCancel">Cancel</button><button type="button" class="primary" id="rmGo">${t.action} and apply</button></div>`,n);if(!l.el.isConnected){
|
|
412
|
+
<div class="actions"><button type="button" id="rmCancel">Cancel</button><button type="button" class="primary" id="rmGo">${t.action} and apply</button></div>`,n);if(!l.el.isConnected){r(!1);return}let d=!1,u=g=>{d||(d=!0,r(g))},m=l.el.querySelector("#rmCancel"),h=l.el.querySelector("#rmGo");m&&(m.onclick=()=>{l.close(),u(!1)}),h&&(h.onclick=()=>{l.close(),u(!0)});let f=new MutationObserver(()=>{l.el.isConnected||(f.disconnect(),u(!1))});f.observe(document.body,{childList:!0}),h?.focus()})}var qa={action:"Save",gerund:"Saving",lead:e=>`This template is used by ${e} post${e===1?" that\u2019s":"s that are"} already scheduled.`},En=(e,t)=>({action:e,gerund:t,lead:n=>`Your name, tagline, address, and logo ride inside every email, including ${n} post${n===1?" that\u2019s":"s that are"} already scheduled.`});function Ls(e){let t=e?.sends||[];if(!t.length)return o`<span class="set-chip">${N("info")}No posts scheduled</span>`;let n=t.length;return o`<span class="set-chip inuse"><span class="set-chip-dot"></span>In use by ${n} scheduled post${n===1?"":"s"}</span>`}function Lu(e,t=Date.now()){let n=e?.retry_after;if(!e||!n||t>=n)return"";let s=Math.max(1,e.sends.filter(i=>i.fire_at<=n).length);return s===1?`A scheduled post sends at ${W(n)}. You can save once it has sent.`:`${s} scheduled posts send by ${W(n)}. You can save once they have sent.`}function Zt(e){let t=Lu(e);return t?o`<span data-remake-wait> ${t}</span>`:null}function Rs(e,t,n){let s=t?.retry_after;s&&mt(s,()=>{for(let i of e.querySelectorAll("[data-remake-wait]"))i.remove()},n)}function Jt(e,t){let n=(t||[]).length;return n?`${e} and applied to ${n} scheduled email${n===1?"":"s"}. Send yourself a test of each.`:e}var ja=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");function Wa(e,t){return e?`<img class="logo" src="${ja(e)}" alt="${ja(t)}" width="44" height="44" />`:""}var Va="You\u2019ve added a mailing address in Settings, but this template doesn\u2019t include the {{ .Publication.Address }} field. If you send promotional email, add it to the footer. If you\u2019ve written your address into the template yourself, or don\u2019t need to provide one, you can ignore this or remove the address in Settings.";function Ru(e,t){let n=Math.min(e,t);return{x:Math.floor((e-n)/2),y:Math.floor((t-n)/2),side:n}}var Fo=class extends Error{constructor(){super("Couldn\u2019t read that image. Pick a PNG, JPEG, WebP, or GIF.")}};async function Ka(e,t){let n=await createImageBitmap(e,{imageOrientation:"from-image"}).catch(()=>{throw new Fo});try{let{x:s,y:i,side:a}=Ru(n.width,n.height),r=document.createElement("canvas");r.width=t,r.height=t;let l=r.getContext("2d");if(!l)throw new Error("This browser can\u2019t prepare the image.");return l.imageSmoothingQuality="high",l.drawImage(n,s,i,a,a,0,0,t,t),{png:await new Promise((u,m)=>r.toBlob(h=>h?u(h):m(new Error("This browser can\u2019t prepare the image.")),"image/png")),width:n.width,height:n.height}}finally{n.close()}}function Cu(e){if(!(e.identity_fields||[]).length)return o`Your email template doesn’t show any of these.`;let t="Shown in every email.",n=e.sends.length;if(!n)return o`${t}`;let s=`Saving a change updates ${n} scheduled email${n===1?"":"s"} too; you\u2019ll confirm first.`;return o`${t} ${s}${Zt(e)}`}var Hu="#/docs/name-your-publication/when-to-provide-a-mailing-address";function _n(){return o`<a class="set-link" href="${Hu}" target="_blank" rel="noopener">When to provide a mailing address <span aria-hidden="true">↗</span><span class="visually-hidden"> (opens in a new tab)</span></a>`}var Uo=256;function Ga(e){let{state:t,fromDisplay:n,fromName:s}=e,i=!!t.address;return o`
|
|
401
413
|
<section class="set-sec">
|
|
402
|
-
${
|
|
414
|
+
${ye("Publication identity",ve("editable","Editable"))}
|
|
403
415
|
<div class="set-card">
|
|
404
416
|
<div class="set-id-grid">
|
|
405
417
|
<div class="set-logo-slot">
|
|
406
418
|
<div class="set-logo-tile${t.logoUrl?" has-img":""}" id="logoTile" role="button" tabindex="0" aria-label="Upload logo">
|
|
407
|
-
<span class="set-logo-ph" id="logoPh"${t.logoUrl?
|
|
419
|
+
<span class="set-logo-ph" id="logoPh"${t.logoUrl?xn:null}>${N("upload")}Upload</span>
|
|
408
420
|
</div>
|
|
409
421
|
<input type="file" id="logoInput" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
|
410
422
|
<div class="set-logo-actions">
|
|
411
423
|
<button type="button" id="logoReplace">${t.logoUrl?"Replace":"Upload"}</button>
|
|
412
|
-
<button type="button" class="danger-subtle" id="logoRemove"${t.logoUrl?null:
|
|
424
|
+
<button type="button" class="danger-subtle" id="logoRemove"${t.logoUrl?null:xn}>Remove</button>
|
|
413
425
|
</div>
|
|
414
426
|
<div class="set-logo-notes">
|
|
415
|
-
<p class="field-hint">Shown as a square. Pick an image at least ${String(
|
|
427
|
+
<p class="field-hint">Shown as a square. Pick an image at least ${String(Uo)} × ${String(Uo)} px; a wider or taller one is cropped to its center square.</p>
|
|
416
428
|
<p class="set-field-warn" id="logoShape" hidden>Your logo isn’t square, so emails show it stretched. Replace it to have it cropped square.</p>
|
|
417
429
|
<div class="field-error" id="logoError" role="alert" hidden><span class="field-error-ico" aria-hidden="true">!</span><span></span></div>
|
|
418
430
|
</div>
|
|
@@ -428,24 +440,24 @@
|
|
|
428
440
|
<input id="setTagline" value="${t.tagline}" placeholder="A one-line description" maxlength="200" autocomplete="off">
|
|
429
441
|
<p class="field-hint">A short line under the name on your public pages.</p>
|
|
430
442
|
</div>
|
|
431
|
-
<div class="set-field set-addr-add" id="addrAdd"${i?
|
|
432
|
-
<button type="button" class="ghost" id="addrAddBtn" aria-describedby="addrAddHint">${
|
|
433
|
-
<p class="field-hint" id="addrAddHint">For promotional email, US law (CAN-SPAM) requires a postal address in the footer. A P.O. box works. ${
|
|
443
|
+
<div class="set-field set-addr-add" id="addrAdd"${i?xn:null}>
|
|
444
|
+
<button type="button" class="ghost" id="addrAddBtn" aria-describedby="addrAddHint">${N("plus")}Add a mailing address</button>
|
|
445
|
+
<p class="field-hint" id="addrAddHint">For promotional email, US law (CAN-SPAM) requires a postal address in the footer. A P.O. box works. ${_n()}</p>
|
|
434
446
|
</div>
|
|
435
|
-
<div class="set-field" id="addrField"${i?null:
|
|
447
|
+
<div class="set-field" id="addrField"${i?null:xn}>
|
|
436
448
|
<div class="set-field-head">
|
|
437
449
|
<label for="setAddress">Mailing address</label>
|
|
438
450
|
<button type="button" class="danger-subtle set-field-remove" id="addrRemove">Remove</button>
|
|
439
451
|
</div>
|
|
440
452
|
<input id="setAddress" value="${t.address}" placeholder="PO Box 123, City, ST 00000" maxlength="300" autocomplete="off">
|
|
441
|
-
<p class="field-hint">${
|
|
442
|
-
<p class="set-field-warn" id="addrWarn" hidden>Your email template currently doesn’t include this field. If you send promotional email, add it to the template. ${
|
|
453
|
+
<p class="field-hint">${_n()}</p>
|
|
454
|
+
<p class="set-field-warn" id="addrWarn" hidden>Your email template currently doesn’t include this field. If you send promotional email, add it to the template. ${_n()}</p>
|
|
443
455
|
</div>
|
|
444
456
|
</div>
|
|
445
457
|
</div>
|
|
446
|
-
<div class="set-note">${
|
|
458
|
+
<div class="set-note">${N("info")}<span>${Cu(e.data.in_use)}</span></div>
|
|
447
459
|
</div>
|
|
448
|
-
</section>`}function
|
|
460
|
+
</section>`}function Ya(e,t,n){let{state:s}=t,i=c("#setName",e),a=c("#setTagline",e),r=c("#setAddress",e),l=()=>{s.name=i.value.trim(),s.tagline=a.value.trim(),s.address=r.value.trim(),t.changed()};i.addEventListener("input",l),a.addEventListener("input",l),r.addEventListener("input",l);let d=!!s.address,u=(t.data.in_use.identity_fields||[]).includes("address"),m=c("#addrAdd",e),h=c("#addrField",e),f=c("#addrWarn",e),g=()=>{m.hidden=d,h.hidden=!d,f.hidden=!(d&&s.address&&!u),f.hidden?r.removeAttribute("aria-describedby"):r.setAttribute("aria-describedby","addrWarn")};r.addEventListener("input",g),c("#addrAddBtn",e).onclick=()=>{d=!0,g(),r.focus()},c("#addrRemove",e).onclick=()=>{r.value="",d=!1,l(),g(),c("#addrAddBtn",e).focus()};let w=c("#logoInput",e),E=c("#logoTile",e),M=c("#logoPh",e),A=c("#logoReplace",e),T=c("#logoRemove",e),$=c("#logoShape",e),p=v=>{if($.hidden=!0,!v)return;let k=new Image;k.onload=()=>{$.hidden=s.logoUrl!==v||k.naturalWidth===k.naturalHeight},k.src=v},x=()=>{let v=!!s.logoUrl;p(s.logoUrl),E.classList.toggle("has-img",v),E.style.backgroundImage=v?`url('${s.logoUrl}')`:"",M.hidden=v,T.hidden=!v,A.textContent=v?"Replace":"Upload"};A.onclick=()=>w.click(),E.addEventListener("click",()=>w.click()),E.addEventListener("keydown",v=>{(v.key==="Enter"||v.key===" ")&&(v.preventDefault(),w.click())});let R=c("#logoError",e),F=c("span:last-of-type",R),V=v=>{F.textContent=v,R.hidden=!v},Z=(v,k)=>{s.logoUrl=v.settings.publication.logo_url||"",t.applySaved(v.settings),x(),t.changed(),O(Jt(k,v.remade))},U=v=>V(rt(v));return w.onchange=async()=>{let v=w.files?.[0];if(w.value="",!!v){V("");try{let k=await Ka(v,Uo),S=k.width!==k.height,C=await _t(L=>{let J=new FormData;J.append("file",new File([k.png],"logo.png",{type:"image/png"}));let D=L?`?remake=${encodeURIComponent(L.join(","))}`:"";return H(`/api/settings/logo${D}`,{method:"POST",body:J})},En("Upload","Uploading"),n);C&&Z(C,S?"Logo updated, cropped to its center square":"Logo updated")}catch(k){U(k)}}},T.onclick=()=>G(T,"Removing\u2026",async()=>{V("");try{let v=await _t(k=>{let S=k?`?remake=${encodeURIComponent(k.join(","))}`:"";return H(`/api/settings/logo${S}`,{method:"DELETE"})},En("Remove","Removing"),n);v&&Z(v,"Logo removed")}catch(v){U(v)}}),x(),g(),{reset(){i.value=s.name,a.value=s.tagline,r.value=s.address,d=!!s.address,g()}}}var Cs=q,Au=[{group:"Post",vars:[{token:"{{ .Post.Body }}",desc:"Your post\u2019s Markdown, rendered to HTML."},{token:"{{ .Post.Subject }}",desc:"The post\u2019s subject line."}]},{group:"Publication",vars:[{token:"{{ .Publication.Name }}",desc:"Publication name (from Identity, above)."},{token:"{{ .Publication.Tagline }}",desc:"Your tagline."},{token:"{{ .Publication.Logo }}",desc:"Your logo as an image, or nothing when no logo is set."},{token:"{{ .Publication.LogoURL }}",desc:"Absolute URL of your logo, or empty."},{token:"{{ .Publication.Address }}",desc:"Your mailing address, if you\u2019ve added one in Settings; empty otherwise. Keep it in the footer if you send promotional email."}]},{group:"Email",vars:[{token:"{{ .Email.SentTo }}",desc:"The recipient\u2019s address (filled per send)."},{token:"{{ .Email.UnsubscribeURL }}",desc:"Their one-click unsubscribe link."},{token:"{{ .Email.ViewInBrowserURL }}",desc:"The web version\u2019s permanent URL."}]},{group:"Email only",vars:[{token:"{{ if .IsEmail }} \u2026 {{ end }}",desc:"Reports whether this is the email or the web version. Put your unsubscribe link and address inside; the web version leaves them out."}]}],qo={signed:{label:"Signed",description:"Your post, then your logo, name, and tagline as a sign-off. Your mailing address sits in the footer once you add one.",html:`<style>
|
|
449
461
|
.email {
|
|
450
462
|
font: 16px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
451
463
|
color: #18181b;
|
|
@@ -600,7 +612,7 @@
|
|
|
600
612
|
<a href="{{ .Email.ViewInBrowserURL }}">View in browser</a>
|
|
601
613
|
<div class="address">{{ .Publication.Address }}</div>{{ end }}
|
|
602
614
|
</div>
|
|
603
|
-
</div>`}},
|
|
615
|
+
</div>`}},Pu=e=>Object.hasOwn(qo,e),Mu=`<div style="position:relative;border:1px dashed #93a7e6;border-radius:8px;padding:20px 14px 8px;margin:0 0 6px"><span style="position:absolute;top:-8px;left:10px;font:650 10px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;text-transform:uppercase;color:#3355cc;background:#fff;padding:0 6px">Your post\u2019s Markdown renders here</span><h2 style="margin:0 0 10px">Lorem ipsum,</h2><p style="margin:0">Dolor sit amet, consectetur adipiscing elit. Proin sed ex ipsum. Suspendisse vulputate nisi et odio dapibus, quis pellentesque felis sollicitudin. Proin vel cursus enim. Phasellus sollicitudin malesuada elementum. Suspendisse euismod eros turpis, ut mollis est imperdiet ut. Sed luctus accumsan erat, at eleifend purus eleifend quis.</p></div>`,Iu=new Set([".Post.Body",".Publication.Logo"]);function Nu(e,t){return String(e).replace(/\{\{\s*(?:if\b[^}]*|end)\s*\}\}/g,"").replace(/\{\{\s*([\w.]+)\s*\}\}/g,(s,i)=>Iu.has(i)?t[i]||"":ae(t[i]??""))}function Bu(e){let t=e.name||"Your publication";return{".Post.Body":Mu,".Post.Subject":"The starlings are back",".Publication.Name":t,".Publication.Tagline":e.tagline||"Your tagline",".Publication.LogoURL":e.logoUrl,".Publication.Logo":Wa(e.logoUrl,t),".Publication.Address":e.address,".Email.SentTo":"you@example.com",".Email.UnsubscribeURL":"#unsubscribe",".Email.ViewInBrowserURL":"#view-in-browser"}}function Ou(e,t){return Nu(e,Bu(t))}var Du='<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0}body{background:#fff}@media (prefers-color-scheme:dark){body{background:#18181b}}.kestrel-email{max-width:640px;margin:0 auto;padding:26px 20px;box-sizing:border-box}.kestrel-email img{max-width:100%}</style></head><body><div class="kestrel-email"></div></body></html>';function jo(e,t,n){e.setAttribute("sandbox",Gt);let s=!1,i=()=>{try{let r=e.contentDocument?.body?.scrollHeight;r&&(e.style.height=`${Math.max(200,r)}px`)}catch{}},a=()=>{let r=e.contentDocument,l=s&&r?r.querySelector(".kestrel-email"):null;l&&(b(l,j(Ou(t(),n()))),Yt(l.ownerDocument),i())};return e.addEventListener("load",()=>{s=!0,a();try{let r=e.contentDocument;r?.body&&typeof ResizeObserver<"u"&&new ResizeObserver(i).observe(r.body)}catch{}}),e.srcdoc=Du,{repaint:a}}function Fu(){return o`${Au.map(e=>o`<div class="set-tpl-vargroup"><h4>${e.group}</h4>${e.vars.map(t=>o`<div class="set-tpl-var"><code>${t.token}</code><span class="set-tpl-var-desc">${t.desc}</span></div>`)}</div>`)}`}async function Wo(e,t){b(e,o`<div class="tpl-page"><div class="page-head"><div class="page-head-row"><h1>Email template</h1><span id="tplInUse"></span></div><p class="set-lede set-page-lede">The template controls the look and feel of the emails you send. You write it as HTML with a <code><style></code> block and <code>{{ variables }}</code> Kestrel fills in; your post’s Markdown is rendered into <code>{{ .Post.Body }}</code>.<span id="tplWait"></span></p></div><div id="tplBody" class="muted">Loading…</div></div>`);let n=c("#tplBody",e),s;try{s=await H("/api/settings",{signal:t})}catch(Y){ne(n,Cs(Y),()=>Q(Wo));return}let i=s.settings,a=s.deployment,r=i.publication,l={name:sn(r.name||"",a.from_address),tagline:r.tagline||"",logoUrl:r.logo_url||"",address:r.address||""},d=i.email_template||"",u=Array.isArray(i.test_recipients)?i.test_recipients:[];b(c("#tplInUse"),Ls(s.in_use)),b(c("#tplWait"),o`${Zt(s.in_use)}`),Rs(e,s.in_use,t),b(n,o`
|
|
604
616
|
<div class="set-preview set-tpl-sample">
|
|
605
617
|
<div class="set-preview-bar">
|
|
606
618
|
<span class="set-preview-titles">
|
|
@@ -612,7 +624,7 @@
|
|
|
612
624
|
<button type="button" class="wtog-btn" data-w="640" aria-pressed="true">640</button>
|
|
613
625
|
<button type="button" class="wtog-btn" data-w="375" aria-pressed="false">375</button>
|
|
614
626
|
</span>
|
|
615
|
-
<button type="button" class="secondary" id="tplTest">${
|
|
627
|
+
<button type="button" class="secondary" id="tplTest">${N("send")}<span id="tplTestLbl">Send test email</span></button>
|
|
616
628
|
</span>
|
|
617
629
|
</div>
|
|
618
630
|
<div class="set-email-stage" id="tplStage">
|
|
@@ -627,8 +639,8 @@
|
|
|
627
639
|
<div class="set-tpl-editor-head">
|
|
628
640
|
<div class="set-tpl-tools">
|
|
629
641
|
<button type="button" class="ghost set-menu-btn" id="tplExamplesBtn" aria-haspopup="menu" aria-expanded="false"><span>Start from example</span><span class="set-menu-caret"></span></button>
|
|
630
|
-
<button type="button" class="set-icon-btn" id="tplLineNums" aria-pressed="false" title="Show line numbers" aria-label="Show line numbers">${
|
|
631
|
-
<button type="button" class="ghost" id="tplCopyAll" title="Copy the whole template to the clipboard" aria-label="Copy the whole template">${
|
|
642
|
+
<button type="button" class="set-icon-btn" id="tplLineNums" aria-pressed="false" title="Show line numbers" aria-label="Show line numbers">${N("lines")}</button>
|
|
643
|
+
<button type="button" class="ghost" id="tplCopyAll" title="Copy the whole template to the clipboard" aria-label="Copy the whole template">${Re()}</button>
|
|
632
644
|
</div>
|
|
633
645
|
<div class="set-tpl-required" aria-label="Required variables">
|
|
634
646
|
<span class="set-req-lbl">Required</span>
|
|
@@ -644,7 +656,7 @@
|
|
|
644
656
|
<div class="set-tpl-msgs" id="tplMsgs" hidden></div>
|
|
645
657
|
</div>
|
|
646
658
|
</div>
|
|
647
|
-
<div class="set-note">${
|
|
659
|
+
<div class="set-note">${N("info")}<span>Kestrel uses this one template for every post, starting from a sensible default. Saving a change while posts are scheduled applies it to their emails too, after you confirm, so nothing scheduled goes out on an older look; their content stays as it was. Sent emails are archived exactly as they went out and never change. Save and Discard are in the bar at the bottom of the page.</span></div>
|
|
648
660
|
</div>
|
|
649
661
|
|
|
650
662
|
<div class="set-card set-tpl-varcard">
|
|
@@ -653,16 +665,16 @@
|
|
|
653
665
|
<h3 class="set-tpl-vartitle">Variables</h3>
|
|
654
666
|
<p class="field-hint">Kestrel replaces these variables with real values when you send an email. Type a variable exactly as shown, or it renders as empty.</p>
|
|
655
667
|
</div>
|
|
656
|
-
<div class="set-tpl-vars-body">${
|
|
668
|
+
<div class="set-tpl-vars-body">${Fu()}</div>
|
|
657
669
|
</div>
|
|
658
|
-
</div>`);let m=c("#tplEditor"),
|
|
659
|
-
`).length;for(let
|
|
660
|
-
`;
|
|
661
|
-
`);let
|
|
662
|
-
`),
|
|
670
|
+
</div>`);let m=c("#tplEditor"),h=jo(c("#tplPreview"),()=>m.value,()=>l),f=c("#tplMsgs"),g=c("#tplTest"),w=c("#tplTestLbl"),E=()=>m.value!==d,M=c("#tplHl"),A=c("code",M),T=c("#tplGutter"),$=()=>{let Y=m.value;b(A,o`${na(Y)}\n`);let re="",ce=Y.split(`
|
|
671
|
+
`).length;for(let $e=1;$e<=ce;$e++)re+=`${$e}
|
|
672
|
+
`;T.textContent=re},p=()=>{M.scrollTop=m.scrollTop,M.scrollLeft=m.scrollLeft,T.scrollTop=m.scrollTop};m.addEventListener("scroll",p);let x=c("#reqBody"),R=c("#reqUnsub"),F=()=>{x.className=`set-req-pill ${/\{\{\s*\.Post\.Body\s*\}\}/.test(m.value)?"ok":"bad"}`,R.className=`set-req-pill ${/\{\{\s*\.Email\.UnsubscribeURL\s*\}\}/.test(m.value)?"ok":"bad"}`},V=_s.attach({onSave:S,onDiscard:v},t);function Z(){$(),F(),p(),V.setDirty(E()),w.textContent=E()?"Save & send test":"Send test email",g.title=E()?"Saves your changes first, then sends: a test always reflects the saved template that will ship.":"Sends a sample post through the saved template so you can see it in a real inbox."}function U(Y){if(!Y.length){f.hidden=!0,b(f,o``);return}f.hidden=!1,f.className="set-tpl-msgs warn",b(f,o`${Y.map(re=>o`<div>${re}${re===Va?o` ${_n()}`:null}</div>`)}`)}function v(){m.value=d,U([]),h.repaint(),Z()}async function k(){let Y=m.value,re=await _t(ce=>H("/api/settings",{method:"PUT",json:ce?{email_template:Y,remake:ce}:{email_template:Y}}),qa,t);return re?(d=re.settings.email_template,m.value=d,X.appConfig={...s,settings:re.settings},h.repaint(),Z(),{warnings:Array.isArray(re.warnings)?re.warnings:[],remade:re.remade||[]}):null}async function S(){try{let Y=await k();if(!Y)return;U(Y.warnings),O(Jt(Y.warnings.length?"Template saved with warnings":"Template saved",Y.remade))}catch(Y){V.showError(Cs(Y))}}let C=Y=>{let re=qo[Pu(Y)?Y:"signed"];m.value=re.html,U([]),h.repaint(),Z()};m.addEventListener("input",()=>{h.repaint(),Z()});let L=c("#tplExamplesBtn");L.onclick=()=>Wt(L,Object.entries(qo).map(([Y,re])=>({label:re.label,description:re.description,onClick:()=>C(Y)})),t,{align:"start"});let J=c("#tplPreview");for(let Y of I(".wtog-btn",n))Y.onclick=()=>{for(let re of I(".wtog-btn",n))re.setAttribute("aria-pressed",String(re===Y));J.style.maxWidth=`${Y.dataset.w}px`,h.repaint()};let D=c("#tplEditorWrap"),se=c("#tplLineNums"),K=Y=>{D.classList.toggle("show-lines",Y),se.setAttribute("aria-pressed",String(Y)),p()},ie=!1;try{ie=localStorage.getItem("kestrel.tpl.lineNums")==="1"}catch{}K(ie),se.onclick=()=>{ie=!ie,K(ie);try{localStorage.setItem("kestrel.tpl.lineNums",ie?"1":"0")}catch{}};let at=c("#tplCopyAll");at.onclick=()=>hn(at,m.value),g.onclick=()=>{let Y=ge(),re=!Y&&E(),ce=ue(o`<h3>Send a test email</h3><p class="hint">Delivers a sample post rendered through your <strong>saved</strong> template, so you can see it in a real inbox. One address per line.</p>${es()}${re?o`<p class="hint" style="color:var(--warn-fg)"><strong>Unsaved changes:</strong> sending will save your template first, so the test reflects what will actually ship.</p>`:null}${Y?null:o`<label for="tplTestTo">Recipients</label><textarea id="tplTestTo" rows="3" placeholder="you@example.com"></textarea><p class="hint" id="tplTestHint" hidden></p>`}<div class="actions"><button type="button" id="ttCancel">Cancel</button><button type="button" class="primary" id="ttGo">${re?"Save & send test":"Send test"}</button></div>`,t),$e=c("#ttGo",ce.el);if(c("#ttCancel",ce.el).onclick=ce.close,$e.disabled=Y,Y){c("#ttCancel",ce.el).focus();return}let _e=c("#tplTestTo",ce.el);if(_e.focus(),u.length&&!_e.value.trim()){_e.value=u.join(`
|
|
673
|
+
`);let Ne=c("#tplTestHint",ce.el);Ne.textContent="Pre-filled from your default test recipients (Settings).",Ne.hidden=!1}$e.onclick=()=>G($e,E()?"Saving\u2026":"Sending\u2026",async()=>{ce.clearError();let{valid:Ne,invalid:Be}=is(_e.value),lt=rs(Be);if(lt){ce.error(lt,_e);return}if(!Ne.length){ce.error("Enter at least one email address",_e);return}if(E())try{let de=await k();if(!de){ce.close();return}U(de.warnings)}catch(de){V.showError(Cs(de)),ce.close();return}try{let de=await H("/api/settings/template/test",{method:"POST",json:{to:Ne}});if(de.failed.length){_e.value=de.failed.join(`
|
|
674
|
+
`),ce.error(as(de.sent,de.total,de.not_sent));return}ce.close(),O(Mt(`Test sent to ${de.sent} address${de.sent===1?"":"es"}`))}catch(de){ce.error(Cs(de))}})},m.value=d,h.repaint(),Z()}function za(e){return o`
|
|
663
675
|
<section class="set-sec">
|
|
664
|
-
${
|
|
665
|
-
<p class="set-lede">The template controls the look and feel of the emails you send. Edit it on the Template page.${
|
|
676
|
+
${ye("Email template",ve("editable","Editable"),Ls(e.data.in_use))}
|
|
677
|
+
<p class="set-lede">The template controls the look and feel of the emails you send. Edit it on the Template page.${Zt(e.data.in_use)}</p>
|
|
666
678
|
<div class="set-preview set-tpl-sample">
|
|
667
679
|
<div class="set-preview-bar">
|
|
668
680
|
<span class="set-preview-lbl">Sample email</span>
|
|
@@ -671,9 +683,9 @@
|
|
|
671
683
|
<iframe class="set-email-frame" id="tplPreview" title="Sample email preview" scrolling="no"></iframe>
|
|
672
684
|
</div>
|
|
673
685
|
<div class="row" style="margin-top:12px"><button type="button" id="tplEditLink">Edit template →</button></div>
|
|
674
|
-
</section>`}function
|
|
686
|
+
</section>`}function Za(e,t){let n=t.data.settings.email_template||"",s=jo(c("#tplPreview",e),()=>n,()=>({name:t.state.name||t.fromDisplay,tagline:t.state.tagline,logoUrl:t.state.logoUrl,address:t.state.address}));return c("#tplEditLink",e).onclick=()=>{location.hash="#/template"},{repaint:()=>s.repaint()}}function Ja(){return o`
|
|
675
687
|
<section class="set-sec">
|
|
676
|
-
${
|
|
688
|
+
${ye("Default test recipients",ve("editable","Editable"))}
|
|
677
689
|
<div class="set-card">
|
|
678
690
|
<div class="set-recip">
|
|
679
691
|
<p class="field-hint" style="margin:0">Pre-filled into <strong>Send test email</strong> so you can proof a post against your own inboxes before scheduling. These are your addresses, and they don’t go through the subscribe-and-confirm flow.</p>
|
|
@@ -684,9 +696,9 @@
|
|
|
684
696
|
</div>
|
|
685
697
|
</div>
|
|
686
698
|
</div>
|
|
687
|
-
</section>`}function
|
|
699
|
+
</section>`}function Qa(e,t){let{state:n}=t,s=c("#recipChips",e),i=c("#recipInput",e),a=()=>{if(!n.recipients.length){b(s,o`<span class="set-recip-empty">No default recipients yet. Add one below.</span>`);return}b(s,o`${n.recipients.map((l,d)=>o`<span class="set-recip-chip">${l}<button type="button" data-rm="${d}" aria-label="Remove ${l}">${N("x")}</button></span>`)}`)};s.addEventListener("click",l=>{let d=l.target instanceof Element?l.target.closest("[data-rm]"):null;d&&(n.recipients.splice(Number(d.dataset.rm),1),a(),t.changed())});let r=()=>{let l=gt(i.value);if(l){if(!vt(l)){Me(i,"That doesn\u2019t look like an email address."),i.focus();return}if(n.recipients.includes(l)){Me(i,"That inbox is already in the list."),i.focus();return}n.recipients.push(l),i.value="",a(),t.changed()}};return c("#recipAdd",e).onclick=r,i.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),r())}),a(),{reset:a}}function Uu(e,t){switch(e){case"cloudflare":return"Cloudflare Email, separate from your newsletter\u2019s provider";case"provider":return t==="none"?"Your email provider, once one is connected":`Your email provider (${Ts(t)})`;default:return"Local stand-in: nothing is delivered"}}var Xa={finished:"went out",refused:"provider refusing",stuck:"in flight too long",wedged:"waiting to be resolved",missed:"missed fire time",import:"list imported",sandbox:"signup refused by the SES sandbox",test:"test"};function el(e){return e.kind==="test"?"test notification":e.subject?`${e.subject} (${Xa[e.kind]})`:Xa[e.kind]}function Vo(e){let{last_sent:t,last_failure:n}=e;return n?o`<span class="set-pill danger">Not delivered</span> <span class="muted">${W(n.at)}, ${el(n)}: ${n.error}</span>`:t?o`<span class="set-pill ok">${N("check")}Delivered</span> <span class="muted">${W(t.at)}, ${el(t)}</span>`:o`<span class="muted">None yet</span>`}function tl(e){let t=e.data.deployment;return o`
|
|
688
700
|
<section class="set-sec">
|
|
689
|
-
${
|
|
701
|
+
${ye("Notifications",ve("editable","Editable"))}
|
|
690
702
|
<p class="set-lede">Kestrel emails you when a send goes out, and right away if a send runs into a problem.</p>
|
|
691
703
|
<div class="set-card">
|
|
692
704
|
<div class="set-recip">
|
|
@@ -698,27 +710,27 @@
|
|
|
698
710
|
<div class="row"><button type="button" class="ghost" id="notifyTest">Send a test notification</button><span class="field-hint" id="notifyTestHint"></span></div>
|
|
699
711
|
</div>
|
|
700
712
|
<div class="set-kv" style="border-top:1px solid var(--line)">
|
|
701
|
-
<div class="set-kv-k">Sent through</div><div class="set-kv-v">${
|
|
702
|
-
<div class="set-kv-k">From address</div><div class="set-kv-v"><span class="mono">${t.
|
|
703
|
-
<div class="set-kv-k">Last notification</div><div class="set-kv-v" id="notifyStatus">${
|
|
713
|
+
<div class="set-kv-k">Sent through</div><div class="set-kv-v">${Uu(t.notify_channel,t.provider)}</div>
|
|
714
|
+
<div class="set-kv-k">From address</div><div class="set-kv-v"><span class="mono">${t.notify_from}</span></div>
|
|
715
|
+
<div class="set-kv-k">Last notification</div><div class="set-kv-v" id="notifyStatus">${Vo(e.data.notification_status)}</div>
|
|
704
716
|
</div>
|
|
705
|
-
<div class="set-note">${
|
|
717
|
+
<div class="set-note">${N("readonly")}<span>The channel and its sender are set at deploy. ${t.notify_channel==="provider"?"Notifications go through your newsletter\u2019s own provider, so one about the provider refusing your account cannot reach you; Cloudflare\u2019s email avoids that. ":""}See the <a class="set-link" href="#/docs/notifications">setup guide</a>.</span></div>
|
|
706
718
|
</div>
|
|
707
|
-
</section>`}function
|
|
708
|
-
<h3 class="set-rp-jhead" id="rpJourney-${e.id}">${
|
|
719
|
+
</section>`}function nl(e,t){let n=c("#notifyTo",e),s=c("#notifyTest",e),i=c("#notifyTestHint",e),a=c("#notifyStatus",e);n.addEventListener("input",()=>{t.state.notifyTo=n.value.trim().toLowerCase(),t.changed()});let r=()=>{let l=t.saved().notifyTo,d=l?t.state.notifyTo!==l?"Save first: the test goes to the saved address.":"":"Save an address to send a test.";s.disabled=!!d,i.textContent=d};return s.onclick=()=>G(s,"Sending\u2026",async()=>{try{let l=await H("/api/settings/notifications/test",{method:"POST"});O(l.channel==="fake"?`Test notification recorded for ${l.to} (local stand-in: nothing is delivered)`:`Test notification sent to ${l.to}`),b(a,Vo({last_sent:{kind:"test",subject:"",at:Date.now()},last_failure:null}))}catch(l){b(a,Vo({last_sent:null,last_failure:{kind:"test",subject:"",at:Date.now(),error:rt(l)}}))}}),r(),{repaint:r,reset(){n.value=t.state.notifyTo,r()}}}var Ko={base:"app",path:"/subscribe"},Hs={base:"app",path:"/confirm?token=\u2026"},Go={base:"app",path:"/unsubscribe?token=\u2026"},Tn=[{id:"subscribe",label:"Subscribe",flow:"steps",states:[{id:"form",label:"Form",at:Ko},{id:"check-inbox",label:"Check your inbox",at:Ko},{id:"not-an-email",label:"Not an email",offPath:!0,at:Ko}]},{id:"confirm",label:"Confirm",flow:"steps",states:[{id:"ready",label:"Ready",at:Hs},{id:"confirmed",label:"Confirmed",at:Hs},{id:"expired",label:"Expired",offPath:!0,at:Hs},{id:"invalid",label:"Invalid link",offPath:!0,at:Hs}]},{id:"unsubscribe",label:"Unsubscribe",flow:"steps",states:[{id:"ask",label:"Ask",at:Go},{id:"done",label:"Unsubscribed",at:Go},{id:"invalid",label:"Invalid link",offPath:!0,at:Go}]},{id:"reading",label:"Reading",flow:"peers",states:[{id:"landing",label:"Landing page",at:{base:"app",path:"/"}},{id:"archive",label:"Archive",at:{base:"archive",path:""}},{id:"post",label:"Post page",at:{base:"archive",path:"/sample-post"}},{id:"landing-empty",label:"Landing, no posts yet",offPath:!0,at:{base:"app",path:"/"}},{id:"archive-empty",label:"Archive, no posts yet",offPath:!0,at:{base:"archive",path:""}},{id:"not-found",label:"Nothing here yet",offPath:!0,at:{base:"archive",path:"/\u2026"}}]}];function sl(e,t){return`/api/reader-pages/${encodeURIComponent(e)}/${encodeURIComponent(t)}`}var qu={subscribe:o`Your name, tagline, and logo head each page; the words are Kestrel’s. The embeddable form in Ways to subscribe posts to these pages too.`,confirm:o`Your identity heads each page. You word the email that links here, under Confirmation email; these pages’ words are fixed, so what a reader agrees to can’t be edited away.`,unsubscribe:o`Your identity heads each page; the words are fixed, so leaving always reads the same. A mail client’s own Unsubscribe button leaves without a page.`,reading:o`Your identity heads each page, and a post page is your <a class="set-link" href="#/template">email template</a> around the post, as its web version shows it. Where a page lists posts, they are samples.`},ju={subscribe:"mail",confirm:"circle-check",unsubscribe:"circle-minus",reading:"book"},Wu=o`<span class="set-rp-dot"></span>`;function ol(e,t,n){return o`<li><button type="button" class="set-rp-item${t.offPath?" off":""}" data-journey="${e.id}" data-state="${t.id}" aria-current="false"><span class="set-rp-mark" aria-hidden="true">${n}</span>${t.label}</button></li>`}function Vu(){return o`${Tn.map(e=>{let t=e.states,n=e.flow==="steps";return o`<div class="set-rp-journey" data-journey="${e.id}">
|
|
720
|
+
<h3 class="set-rp-jhead" id="rpJourney-${e.id}">${N(ju[e.id])}${e.label}</h3>
|
|
709
721
|
<ul class="set-rp-list${n?" steps":""}" aria-labelledby="rpJourney-${e.id}">
|
|
710
|
-
${t.filter(s=>!s.offPath).map((s,i)=>
|
|
722
|
+
${t.filter(s=>!s.offPath).map((s,i)=>ol(e,s,n?String(i+1):Wu))}
|
|
711
723
|
</ul>
|
|
712
724
|
<ul class="set-rp-list" aria-label="${e.label}, off the main path">
|
|
713
|
-
${t.filter(s=>s.offPath).map(s=>
|
|
725
|
+
${t.filter(s=>s.offPath).map(s=>ol(e,s,N("branch")))}
|
|
714
726
|
</ul>
|
|
715
|
-
</div>`})}`}function
|
|
727
|
+
</div>`})}`}function Ku(){return o`<select id="rpSelect" aria-label="Reader page">${Tn.map(e=>{let t=e.states,n=0;return o`<optgroup label="${e.label}">${t.map(s=>{let i=e.flow==="steps"&&!s.offPath?`${++n}. `:"";return o`<option value="${e.id}/${s.id}">${i}${s.label}</option>`})}</optgroup>`})}</select>`}function il(e){return o`
|
|
716
728
|
<section class="set-sec set-rp-sec" id="readerPages">
|
|
717
729
|
${e}
|
|
718
730
|
<p class="set-lede">Every page a reader can land on, exactly as they see it, with your publication’s identity and sample values. Nothing here changes a page; it shows the ones that are hard to reach by hand.</p>
|
|
719
731
|
<div class="set-card set-rp">
|
|
720
|
-
<nav class="set-rp-nav" aria-label="Reader pages">${
|
|
721
|
-
<div class="set-rp-pick">${
|
|
732
|
+
<nav class="set-rp-nav" aria-label="Reader pages">${Vu()}</nav>
|
|
733
|
+
<div class="set-rp-pick">${Ku()}</div>
|
|
722
734
|
<div class="set-rp-main">
|
|
723
735
|
<div class="set-rp-top">
|
|
724
736
|
<div>
|
|
@@ -726,8 +738,8 @@
|
|
|
726
738
|
<p class="set-rp-note" id="rpNote"></p>
|
|
727
739
|
</div>
|
|
728
740
|
<span class="set-wtog" id="rpWidth" role="group" aria-label="Preview width">
|
|
729
|
-
<button type="button" class="wtog-btn" data-width="desktop" aria-pressed="true">${
|
|
730
|
-
<button type="button" class="wtog-btn" data-width="phone" aria-pressed="false">${
|
|
741
|
+
<button type="button" class="wtog-btn" data-width="desktop" aria-pressed="true">${N("desktop")}Desktop</button>
|
|
742
|
+
<button type="button" class="wtog-btn" data-width="phone" aria-pressed="false">${N("phone")}Phone</button>
|
|
731
743
|
</span>
|
|
732
744
|
</div>
|
|
733
745
|
<div class="set-rp-at"><span class="mono" id="rpAt"></span><span>Sample · links and forms do nothing</span></div>
|
|
@@ -737,7 +749,7 @@
|
|
|
737
749
|
</div>
|
|
738
750
|
</div>
|
|
739
751
|
</div>
|
|
740
|
-
</section>`}function
|
|
752
|
+
</section>`}function Gu(e,t){return`${e.base==="app"?t.app_origin:`${t.archive_origin}${t.archive_base_path}`}${e.path}`.replace(/^https?:\/\//,"")}function rl(e,t,n){let s=c(".set-rp-nav",e),i=c("#rpSelect",e),a=c("#rpWidth",e),r=c("#rpStage",e),l=c("#rpFrame",e),d=c("#rpError",e),u=Tn[0],m=u.states[0],h="desktop",f=0,g=null;ft(n,()=>g?.disconnect());let w=()=>{for(let T of I(".set-rp-item",s)){let $=T.dataset.journey===u.id&&T.dataset.state===m.id;T.setAttribute("aria-current",$?"true":"false")}for(let T of I(".set-rp-journey",s))T.classList.toggle("current",T.dataset.journey===u.id);i.value=`${u.id}/${m.id}`,c("#rpJourney",e).textContent=u.label,c("#rpState",e).textContent=m.label,b(c("#rpNote",e),qu[u.id]),c("#rpAt",e).textContent=Gu(m.at,t)},E=()=>{let T=l.contentDocument?.body?.scrollHeight;T&&(l.style.height=`${T}px`)},M=async()=>{let T=++f;try{let $=await At(sl(u.id,m.id),{signal:n});if(T!==f||n.aborted)return;d.hidden=!0,r.hidden=!1,l.srcdoc=ys($)}catch($){if(T!==f||n.aborted)return;l.srcdoc="",r.hidden=!0,d.textContent=`Couldn\u2019t load this page: ${q($)}`,d.hidden=!1}},A=(T,$)=>{let p=Tn.find(F=>F.id===T),R=(p?.states??[]).find(F=>F.id===$);!p||!R||p===u&&R===m||(u=p,m=R,w(),M())};return l.onload=()=>{let T=l.contentDocument;!T||n.aborted||(T.addEventListener("click",$=>{$.target?.closest?.("a[href], area[href]")&&$.preventDefault()}),g?.disconnect(),E(),T.body&&typeof ResizeObserver<"u"&&(g=new ResizeObserver(E),g.observe(T.body)))},s.addEventListener("click",T=>{let $=T.target.closest(".set-rp-item");A($?.dataset.journey,$?.dataset.state)}),i.addEventListener("change",()=>{let[T,$]=i.value.split("/");A(T,$)}),a.addEventListener("click",T=>{let p=T.target.closest("button[data-width]")?.dataset.width;if(!(p!=="desktop"&&p!=="phone"||p===h)){h=p;for(let x of I("button",a))x.setAttribute("aria-pressed",String(x.dataset.width===p));r.classList.toggle("phone",p==="phone")}}),w(),M(),{refresh:()=>{M()}}}function ll(e){return e?`Subscribe to ${e}`:"Subscribe"}function Yu(e,t,n){let s=ae(ll(t)),i=`${ae(n)}/subscribe`;return e==="styled"?`<form action="${i}" method="post" style="max-width:460px;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
|
|
741
753
|
<div style="font:600 22px/1.2 Georgia,'Times New Roman',serif;letter-spacing:-0.01em;margin-bottom:12px">${s}</div>
|
|
742
754
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
743
755
|
<input type="email" name="email" required placeholder="you@example.com" aria-label="Email address" style="flex:1 1 220px;padding:11px 13px;border:1px solid #d6d3cc;border-radius:9px;background:#fff;color:#1b1a17;font:inherit">
|
|
@@ -750,14 +762,14 @@
|
|
|
750
762
|
<input type="email" name="email" placeholder="you@example.com" required>
|
|
751
763
|
</label>
|
|
752
764
|
<button type="submit">Subscribe</button>
|
|
753
|
-
</form>`}function
|
|
765
|
+
</form>`}function zu(e,t){let n=ll(t);return e==="styled"?o`<form class="set-pf-styled"><span class="l">${n}</span><div class="rowf"><input type="email" placeholder="you@example.com" disabled><button type="button" class="sub-btn" tabindex="-1">Subscribe</button></div><p class="set-pf-fine">We’ll email you a link to confirm. Unsubscribe anytime.</p></form>`:o`<form class="set-pf-plain"><label>${n} <input type="email" placeholder="you@example.com" disabled></label> <button type="button" tabindex="-1">Subscribe</button></form>`}function cl(e){return e.data.deployment.app_origin||location.origin}function Zu(e){return e?"A public page with your publication\u2019s name and a sign-up form ready to go.":"A public page with a sign-up form ready to go."}function al(e,t,n,s,i=null){return o`<div class="set-way"><div class="set-way-head"><div class="set-way-name"><h3 class="set-way-title">${N(e)}${t}</h3><p class="set-way-line">${n}</p></div>${i}</div>${s}</div>`}function dl(e){let t=`${cl(e)}/subscribe`;return o`
|
|
754
766
|
<section class="set-sec set-sec-subscribe">
|
|
755
|
-
${
|
|
767
|
+
${ye("Ways to subscribe",ve("copyout","Copy-out"))}
|
|
756
768
|
<p class="set-lede">Two ways for readers to join your list. Either way, a reader is added only once they confirm by email.</p>
|
|
757
|
-
${e.data.deployment.provider==="none"?o`<div class="health amber set-sub-not-connected" role="status"><span class="health-dot">⚠️</span><div><span>No email provider connected, so signups aren’t open yet: the page below says so, and the form takes no address. ${
|
|
769
|
+
${e.data.deployment.provider==="none"?o`<div class="health amber set-sub-not-connected" role="status"><span class="health-dot">⚠️</span><div><span>No email provider connected, so signups aren’t open yet: the page below says so, and the form takes no address. ${He()} to open signups.</span></div></div>`:null}
|
|
758
770
|
<div class="set-card">
|
|
759
|
-
${
|
|
760
|
-
${
|
|
771
|
+
${al("link","Share the page",o`<span id="subscribePageLine"></span>`,o`<div class="set-way-body set-way-addr"><code class="set-addr">${t}</code>${fn(t,"Copy the subscribe page\u2019s address")}<a class="ghost-link" href="${t}" target="_blank" rel="noopener">Open ↗</a></div>`)}
|
|
772
|
+
${al("code","Embed a form",o`Put a sign-up form on another site you own.`,o`<div class="set-embed">
|
|
761
773
|
<div class="set-embed-preview" id="embedPreview"></div>
|
|
762
774
|
<pre class="set-embed-code"><code id="embedCode"></code></pre>
|
|
763
775
|
</div>
|
|
@@ -766,30 +778,30 @@
|
|
|
766
778
|
<button type="button" class="wtog-btn" data-embed="plain" aria-pressed="false">Plain</button>
|
|
767
779
|
</span>`)}
|
|
768
780
|
</div>
|
|
769
|
-
</section>`}function
|
|
770
|
-
${
|
|
771
|
-
${
|
|
772
|
-
${
|
|
773
|
-
${
|
|
774
|
-
${
|
|
775
|
-
`),
|
|
781
|
+
</section>`}function Ju(e,t){return o`<button type="button" class="secondary set-embed-copy" data-copy="${t}" data-copy-word="Copy HTML" aria-label="Copy HTML, ${e==="styled"?"Styled":"Plain"} form">${Re(!1,"Copy HTML")}</button>`}function ul(e,t){let n=cl(t),s=c("#embedCode",e),i=c("#embedCopy",e),a=c("#embedPreview",e),r=c("#subscribePageLine",e),l="styled",d=()=>t.state.name||t.fromDisplay,u=()=>{let f=Yu(l,d(),n);s.textContent=f,b(i,Ju(l,f)),b(a,zu(l,d())),r.textContent=Zu(d())},m=I("[data-embed]",e);for(let f of m)f.onclick=()=>{l=f.dataset.embed==="styled"?"styled":"plain";for(let g of m)g.setAttribute("aria-pressed",String(g.dataset.embed===l));u()};ot(c(".set-sec-subscribe",e)),u();let h=d();return{repaint(){d()!==h&&(h=d(),u())}}}function Ln(e,t){return o`<div class="set-group" data-group="${e}">${t}</div>`}function Yo(e){return{name:e.name,tagline:e.tagline,address:e.address,recipients:[...e.recipients],confirmation:{...e.confirmation},notifyTo:e.notifyTo}}var Qu=(e,t)=>e.length===t.length&&e.every((n,s)=>n===t[s]),Xu=(e,t)=>e.subject===t.subject&&e.body===t.body&&e.button_label===t.button_label&&e.reassurance===t.reassurance;async function zo(e,t){b(e,o`<div class="settings"><div class="page-head"><h1>Settings</h1><p class="set-lede set-page-lede">Your publication’s identity, the ways readers subscribe and the pages they land on, the email each post is sent inside, and where Kestrel tells you about your sends. Facts set when Kestrel was deployed come last, read-only.</p></div><div id="settingsBody" class="muted">Loading…</div></div>`);let n=c("#settingsBody",e),s;try{s=await H("/api/settings",{signal:t})}catch(p){ne(n,rt(p),()=>Q(zo));return}let i=s.settings,a=i.publication,r=i.confirmation_email,l={name:a.name||"",tagline:a.tagline||"",address:a.address||"",logoUrl:a.logo_url||"",recipients:[...i.test_recipients||[]],confirmation:{subject:r.subject||"",body:r.body||"",button_label:r.button_label||"",reassurance:r.reassurance||""},notifyTo:i.notifications.to||""},d=Yo(l),u=eo(s.deployment.from_address),m=()=>l.name!==d.name||l.tagline!==d.tagline||l.address!==d.address||!Qu(l.recipients,d.recipients)||!Xu(l.confirmation,d.confirmation)||l.notifyTo!==d.notifyTo,h=[],f=_s.attach({onSave:T,onDiscard:$},t),g=()=>{},w={data:s,state:l,saved:()=>d,fromDisplay:u,fromName:u||"Your publication",changed(){for(let p of h)p.repaint?.();f.setDirty(m())},applySaved(p){X.appConfig={...s,settings:p},Wn(),g()}};b(n,o`
|
|
782
|
+
${Ln("publication",[Ga(w)])}
|
|
783
|
+
${Ln("readers",[dl(w),Oa(w),il(ye("Reader pages",ve("preview","Preview")))])}
|
|
784
|
+
${Ln("emails",[za(w),Ja()])}
|
|
785
|
+
${Ln("you",[tl(w)])}
|
|
786
|
+
${Ln("deploy",[Fa(w),Ua(w)])}
|
|
787
|
+
`),h.push(Ya(n,w,t),ul(n,w),Da(n,w),Za(n,w),Qa(n,w),nl(n,w)),Rs(n,s.in_use,t),g=rl(n,s.deployment,t).refresh;let M=null,A=()=>{M?.(),M=null};async function T(){A();try{let p={publication:{name:l.name,tagline:l.tagline,address:l.address},test_recipients:l.recipients,confirmation_email:l.confirmation,notifications:{to:l.notifyTo}},x=await _t(F=>H("/api/settings",{method:"PUT",json:F?{...p,remake:F}:p}),En("Save","Saving"),t);if(!x)return;let R=x.settings;l.name=R.publication.name,l.tagline=R.publication.tagline,l.address=R.publication.address,l.recipients=[...R.test_recipients],l.confirmation={...R.confirmation_email},l.notifyTo=R.notifications.to,d=Yo(l);for(let F of h)F.reset?.();w.applySaved(R),w.changed(),O(Jt("Settings saved",x.remade))}catch(p){let x=p instanceof ee?tp(e,p.field):null;x&&p instanceof ee?(M=Me(x,rt(p)),x.focus(),f.showError("Not saved: fix the field marked above.")):f.showError(rt(p))}}function $(){A(),Object.assign(l,Yo(d));for(let p of h)p.reset?.();w.changed()}w.changed()}var ep={"publication.name":"#setName","publication.tagline":"#setTagline","publication.address":"#setAddress",test_recipients:"#recipInput","notifications.to":"#notifyTo","confirmation_email.subject":"#ceSubject","confirmation_email.body":"#ceMessage","confirmation_email.button_label":"#ceButton","confirmation_email.reassurance":"#ceFooter"};function tp(e,t){let n=t?ep[t]:void 0,s=n?e.querySelector(n):null;return(s instanceof HTMLInputElement||s instanceof HTMLTextAreaElement)&&!s.closest("[hidden]")?s:null}var np="kestrel-subscribers/1",Rn="kestrel-subscribers/2",sp=[np,Rn],op=["subscribed","pending","unsubscribed","none"],Zo=["double","single","unknown"],ip=["bounce","manual"];var rp=["reader","publisher","complaint","erased"],pl=["subscribed","unsubscribed","bounced"],ap={maxRows:1e4,maxRowsFree:1e3,maxBodyBytes:10*1024*1024,maxEvidenceBytes:500};var ml=[{name:"email",required:!0,values:null,takes:"an email address; trimmed and lowercased before it is matched",meaning:"The address. An invalid one is still a row, counted as invalid and reported, never dropped."},{name:"status",required:!0,values:op,takes:"one of the values",meaning:"The consent claim. `none` is a known address with no consent claim, present only to carry a suppression."},{name:"opt_in",required:!0,values:Zo,takes:"one of the values",meaning:"How consent was collected. `unknown` unless the source states it."},{name:"origin",required:!0,values:null,takes:"a short lowercase name (letters, digits, `.`, `_`, `-`, up to 40): `sendy`, `mailchimp`, `kestrel`, `unknown`",meaning:"Where consent was first collected, as the file claims it."},{name:"unsubscribe_cause",required:!1,values:rp,takes:"one of the values, or empty; only on an `unsubscribed` row",meaning:"Why the person left: their own unsubscribe (`reader`), the publisher on their request (`publisher`), a complaint about the mail (`complaint`), or an erasure at their request (`erased`), which is recorded with the address alone. Empty when the source doesn\u2019t say, recorded as having left elsewhere. Version 2 only."},{name:"suppression",required:!1,values:ip,takes:"one of the values, or empty",meaning:"A suppression on top of `status`, the sender\u2019s side: the address bounced, or was blocked by hand. In version 1 it also took `complaint`, read as an `unsubscribed` row whose cause is `complaint`, and `erased`, read as one whose cause is `erased`."},{name:"subscribed_at",required:!1,values:null,takes:"a UTC ISO-8601 instant with its zone (`2024-03-01T09:30:00Z`), or empty",meaning:"When the person subscribed, if the source states it."},{name:"confirmed_at",required:!1,values:null,takes:"an instant with its zone, or empty",meaning:"When the person confirmed, if the source states it."},{name:"unsubscribed_at",required:!1,values:null,takes:"an instant with its zone, or empty",meaning:"When the person left, if the source states it. A reader who confirmed here after it stays confirmed."},{name:"suppressed_at",required:!1,values:null,takes:"an instant with its zone, or empty",meaning:"When the address was suppressed, if the source states it."},{name:"evidence",required:!1,values:null,takes:`a JSON object of at most ${ap.maxEvidenceBytes} bytes, or empty`,meaning:"The source\u2019s consent-relevant columns, verbatim (a signup IP, a form name, a confirmation time). Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion."},{name:"format",required:!1,values:sp,takes:"the format\u2019s name and version, or empty",meaning:"The version the row is written in. A row without it may use either version\u2019s values, which every version reads alike; a version this release doesn\u2019t know is refused by name."}],ww=new Set(ml.map(e=>e.name));var lp=/^[=+\-@\t\r]/;function cp(e){return lp.test(e)?`'${e}`:e}function dp(e){let t=cp(e);return/[",\r\n]/.test(t)||t!==t.trim()?`"${t.replace(/"/g,'""')}"`:t}function Jo(e){return`${e.map(t=>t.map(dp).join(",")).join(`\r
|
|
776
788
|
`)}\r
|
|
777
|
-
`}function
|
|
789
|
+
`}function Qo(e){let t=ml.map(n=>n.name);return Jo([t,...e.map(n=>t.map(s=>n[s]??""))])}var Xo=[{email:"ada@example.com",status:"subscribed",opt_in:"double",origin:"sendy",unsubscribe_cause:null,suppression:null,subscribed_at:"2024-03-01T09:30:00Z",confirmed_at:"2024-03-01T09:42:00Z",unsubscribed_at:null,suppressed_at:null,evidence:'{"signup_ip":"203.0.113.7","list":"Field Notes"}',format:Rn},{email:"grace@example.com",status:"subscribed",opt_in:"single",origin:"mailchimp",unsubscribe_cause:null,suppression:null,subscribed_at:"2023-11-12T16:05:00Z",confirmed_at:null,unsubscribed_at:null,suppressed_at:null,evidence:null,format:Rn},{email:"edsger@example.com",status:"unsubscribed",opt_in:"unknown",origin:"unknown",unsubscribe_cause:"reader",suppression:null,subscribed_at:null,confirmed_at:null,unsubscribed_at:"2025-01-20T08:00:00Z",suppressed_at:null,evidence:null,format:Rn},{email:"bounced@example.com",status:"none",opt_in:"unknown",origin:"unknown",unsubscribe_cause:null,suppression:"bounce",subscribed_at:null,confirmed_at:null,unsubscribed_at:null,suppressed_at:"2025-02-02T12:00:00Z",evidence:null,format:Rn}],Sw=Qo(Xo),ei=`ada@example.com\r
|
|
778
790
|
grace@example.com\r
|
|
779
791
|
linus@example.com\r
|
|
780
|
-
`,
|
|
792
|
+
`,fl="kestrel-import-example.csv",ti="kestrel-import-example.txt";var up={buttondown:"Buttondown",convertkit:"ConvertKit",kit:"Kit",kestrel:"Kestrel",mailchimp:"Mailchimp",mailerlite:"MailerLite",sendy:"Sendy",substack:"Substack",beehiiv:"beehiiv",ghost:"Ghost"};function Cn(e){return e===null||e===""||e==="unknown"?null:up[e]??e.charAt(0).toUpperCase()+e.slice(1)}function Hn(e){return e===null||e==="unknown"?"opt-in unknown":`${e} opt-in`}function hl(e){return e.import_id!==null?`Imported \xB7 ${[Cn(e.origin),Hn(e.opt_in)].filter(Boolean).join(", ")}`:e.confirmed_at!==null?"Double opt-in":null}function bl(e){if(!e)return null;try{return JSON.stringify(JSON.parse(e),null,2)}catch{return e}}function As(e){return e.principal.kind==="service"?"Claude, for you":e.principal.email||"you"}var gl=(e,t,n)=>`${e.toLocaleString("en-US")} ${e===1?t:n}`,pp=[{outcome:"added_confirmed",one:"address added as a confirmed subscriber",many:"addresses added as confirmed subscribers"},{outcome:"confirmed_pending",one:"pending subscriber here confirmed",many:"pending subscribers here confirmed"},{outcome:"unsubscribed",one:"subscriber here unsubscribed",many:"subscribers here unsubscribed"},{outcome:"erased",one:"subscriber here erased, as the file says: only the address is kept, and past sends show an erased recipient",many:"subscribers here erased, as the file says: only their addresses are kept, and past sends show an erased recipient"},{outcome:"added_unsubscribed",one:"address recorded as having left (unsubscribed), so the list remembers them",many:"addresses recorded as having left (unsubscribed), so the list remembers them"},{outcome:"suppressed",one:"address suppressed as the file says (bounced or blocked), so it\u2019s never mailed",many:"addresses suppressed as the file says (bounced or blocked), so they\u2019re never mailed"},{outcome:"unchanged",one:"address already here as the file says, so it\u2019s unchanged",many:"addresses already here as the file says, so they\u2019re unchanged"},{outcome:"left_out",one:"row still pending, or with no status, left out, since an import sends no email",many:"rows still pending, or with no status, left out, since an import sends no email"},{outcome:"invalid",one:"row isn\u2019t a valid address",many:"rows aren\u2019t valid addresses"},{outcome:"merged",one:"row repeats an address earlier in the file, and is folded into it",many:"rows repeat an address earlier in the file, and are folded into it"},{outcome:"kept_suppressed",one:"address is suppressed here, so it isn\u2019t imported",many:"addresses are suppressed here, so they aren\u2019t imported",kept:!0},{outcome:"kept_unsubscribed",one:"reader unsubscribed here, and stays unsubscribed",many:"readers unsubscribed here, and stay unsubscribed",kept:!0},{outcome:"kept_confirmed_newer",one:"reader confirmed here after the file says they left, and stays confirmed",many:"readers confirmed here after the file says they left, and stay confirmed",kept:!0}];function ni(e){return pp.filter(t=>e[t.outcome]>0).map(t=>({outcome:t.outcome,count:e[t.outcome],text:gl(e[t.outcome],t.one,t.many),kept:t.kept===!0}))}function vl(e){return e.added_confirmed+e.confirmed_pending}function si(e,t){let n=t-e.suppressed;return n>0?`${gl(n,"of the addresses above also gains","of the addresses above also gain")} a suppression, so ${n===1?"it\u2019s":"they\u2019re"} never mailed.`:null}function yl(e){let t=Zo.filter(s=>(e.opt_in[s]??0)>0).map(s=>`${e.opt_in[s].toLocaleString("en-US")} ${Hn(s)}`).join(", "),n=Object.entries(e.origin).filter(([,s])=>s>0).sort((s,i)=>i[1]-s[1]||s[0].localeCompare(i[0])).map(([s,i])=>`${Cn(s)??"unknown"} ${i.toLocaleString("en-US")}`).join(", ");return{optIn:t,origin:n}}var Qt={added_confirmed:"Imported as subscribed",confirmed_pending:"Imported as subscribed",unsubscribed:"Unsubscribed",erased:"Erased",added_unsubscribed:"Recorded as having left",suppressed:"Suppressed",unchanged:"Unchanged",left_out:"Left out",invalid:"Invalid",merged:"Folded into an earlier row",kept_suppressed:"Already suppressed here",kept_unsubscribed:"Kept unsubscribed",kept_confirmed_newer:"Kept confirmed"};function wl(e,t,n){let s=new Map(e.map(r=>[r.row,r])),i=new Map(t.map(r=>[r.row,r])),a=new Map;for(let r of new Set([...s.keys(),...i.keys()])){let l=s.get(r),d=i.get(r),u=l?Qt[l.outcome]:Qt.added_confirmed,m=d?Qt[d.outcome]:Qt.added_confirmed;u!==m&&a.set(r,{row:r,email:(d??l)?.email??"",planned:u,applied:m})}for(let r of n){let l=Qt[r.planned],d=Qt[r.applied];!a.has(r.row)&&l!==d&&a.set(r.row,{row:r.row,email:r.email,planned:l,applied:d})}return[...a.values()].sort((r,l)=>r.row-l.row)}function Sl(e){return Jo([["row","email","outcome","suppression_added"],...e.map(t=>[String(t.row),t.email,t.outcome,t.suppression_added?"yes":"no"])])}var $l="Please import my Sendy subscribers from ~/Downloads/sendy-export.csv into the newsletter.",mp="Your list or its scheduled posts changed since this plan, so here is the plan again as things stand now. Review it, then import.",oi=["email","status","opt_in","origin","subscribed_at"],fp={subscribed:"Subscribed",unsubscribed:"Unsubscribed",bounced:"Bounced"};async function ri(e,t){b(e,o`<div class="imp">
|
|
781
793
|
<div class="editor-head"><a href="#/subscribers" class="back">← Subscribers</a></div>
|
|
782
794
|
<div class="page-head"><h1>Import subscribers</h1><p class="set-lede" id="impLede">Nothing changes until you review what will happen and confirm.</p></div>
|
|
783
795
|
<div id="impBody" class="muted">Loading…</div>
|
|
784
796
|
<input type="file" id="impInput" accept=".csv,.txt,text/csv,text/plain" hidden>
|
|
785
|
-
</div>`);let n=c("#impBody",e),s=c("#impInput",e);
|
|
797
|
+
</div>`);let n=c("#impBody",e),s=c("#impInput",e);ot(n);let i;try{i=await H("/api/imports/format",{signal:t})}catch(p){t.aborted||ne(n,q(p),()=>{Q(ri)});return}if(t.aborted)return;let a="full";function r(p){c("#impLede",e).hidden=!1;let x=i.limits.max_rows.toLocaleString("en-US");b(n,o`<section class="card imp-card">
|
|
786
798
|
<div class="imp-card-head"><h2>The file Kestrel reads</h2>
|
|
787
799
|
<span class="set-wtog" role="group" aria-label="Which file">
|
|
788
|
-
<button type="button" class="wtog-btn" data-sample="full" aria-pressed="${String(
|
|
789
|
-
<button type="button" class="wtog-btn" data-sample="plain" aria-pressed="${String(
|
|
800
|
+
<button type="button" class="wtog-btn" data-sample="full" aria-pressed="${String(a==="full")}">Full list</button>
|
|
801
|
+
<button type="button" class="wtog-btn" data-sample="plain" aria-pressed="${String(a==="plain")}">Plain list</button>
|
|
790
802
|
</span>
|
|
791
803
|
</div>
|
|
792
|
-
<div id="impSample">${
|
|
804
|
+
<div id="impSample">${a==="full"?l():d()}</div>
|
|
793
805
|
</section>
|
|
794
806
|
<section class="card imp-card imp-claude">
|
|
795
807
|
<h2>Just ask Claude</h2>
|
|
@@ -797,81 +809,119 @@ linus@example.com\r
|
|
|
797
809
|
${u()}
|
|
798
810
|
</section>
|
|
799
811
|
<div class="imp-drop" id="impDrop">
|
|
800
|
-
${
|
|
812
|
+
${N("upload")}
|
|
801
813
|
<p class="imp-drop-line">Drop your file here, or</p>
|
|
802
814
|
<button type="button" class="primary" id="impChoose" aria-describedby="impLimit${p?" impFileError":""}">Choose file</button>
|
|
803
815
|
<p class="field-hint" id="impLimit">CSV or .txt, up to ${x} rows.${i.limits.max_rows_reason?o` ${i.limits.max_rows_reason}`:null}</p>
|
|
804
816
|
</div>
|
|
805
817
|
${p?o`<div class="imp-file-error"><div class="field-error" id="impFileError" role="alert"><span class="field-error-ico" aria-hidden="true">!</span><span>${p}</span></div>
|
|
806
|
-
<p class="imp-text">Exported from another service? Claude can convert it for you. Ask Claude Code:</p>${u()}</div>`:null}`);for(let
|
|
807
|
-
<div class="table-wrap"><table class="imp-sample"><thead><tr>${
|
|
808
|
-
<tbody>${p.map(
|
|
809
|
-
<p class="field-hint">Optional columns: ${x.map((
|
|
818
|
+
<p class="imp-text">Exported from another service? Claude can convert it for you. Ask Claude Code:</p>${u()}</div>`:null}`);for(let F of I("[data-sample]",n))F.onclick=()=>{a=F.dataset.sample==="plain"?"plain":"full";for(let V of I("[data-sample]",n))V.setAttribute("aria-pressed",String(V.dataset.sample===a));b(c("#impSample",n),a==="full"?l():d()),m()};m(),c("#impChoose",n).onclick=()=>s.click();let R=c("#impDrop",n);R.addEventListener("dragover",F=>{F.preventDefault(),R.classList.add("over")}),R.addEventListener("dragleave",()=>R.classList.remove("over")),R.addEventListener("drop",F=>{F.preventDefault(),R.classList.remove("over");let V=F.dataTransfer?.files[0];V&&h(V)})}function l(){let p=Xo.slice(0,3),x=i.columns.filter(R=>!R.required&&!oi.includes(R.name)).map(R=>R.name);return o`<p class="imp-text">A CSV with a header row, one row per address. A row says what your old service recorded about each person; Kestrel decides what that means here.</p>
|
|
819
|
+
<div class="table-wrap"><table class="imp-sample"><thead><tr>${oi.map(R=>o`<th><code>${R}</code></th>`)}</tr></thead>
|
|
820
|
+
<tbody>${p.map(R=>o`<tr>${oi.map(F=>o`<td>${R[F]??""}</td>`)}</tr>`)}</tbody></table></div>
|
|
821
|
+
<p class="field-hint">Optional columns: ${x.map((R,F)=>o`${F?", ":""}<code>${R}</code>`)}.</p>
|
|
810
822
|
<div class="row imp-links"><button type="button" class="ghost" data-template="csv">Download example file (CSV)</button><a class="ghost-link" href="#/docs/your-list/the-file-format">Import format reference →</a></div>`}function d(){return o`<p class="imp-text">A <code>.txt</code> file with one address per line.</p>
|
|
811
|
-
${
|
|
812
|
-
`).trim()}`,
|
|
823
|
+
${Kt(ti,o`${ei.replace(/\r\n/g,`
|
|
824
|
+
`).trim()}`,ei)}
|
|
813
825
|
<p class="imp-text">Next you’ll say what they are: subscribed (the default), unsubscribed, or bounced.</p>
|
|
814
|
-
<div class="row imp-links"><button type="button" class="ghost" data-template="txt">Download example file (.txt)</button></div>`}function u(){return
|
|
826
|
+
<div class="row imp-links"><button type="button" class="ghost" data-template="txt">Download example file (.txt)</button></div>`}function u(){return Kt("Prompt for Claude Code",o`${$l}`,$l)}function m(){for(let p of I("[data-template]",n))p.onclick=()=>G(p,null,async()=>{let x=p.dataset.template==="txt"?"txt":"csv";try{let R=await jn(`/api/imports/template.${x}`);Et(R.text,R.filename??(x==="txt"?ti:fl),x==="txt"?"text/plain":"text/csv")}catch(R){O(q(R))}})}s.onchange=()=>{let p=s.files?.[0];s.value="",p&&h(p)};async function h(p){let x=await p.text();if(t.aborted)return;let R={name:p.name,text:x},F=hp(R)?i.plain_list.default:null,V=n.querySelector("#impChoose, #impReplace"),Z=await(V?G(V,"Reading\u2026",()=>f(R,F)):f(R,F));Z!==null&&(g(Z,R),c(".imp-file",n).focus())}async function f(p,x){try{return await H("/api/imports/plan",{method:"POST",json:x===null?{file:p}:{file:p,plain_list_status:x},signal:t})}catch(R){return t.aborted||(r(q(R)),c("#impChoose",n).focus()),null}}function g(p,x,R){let F=vl(p.counts),V=ni(p.counts),Z=yl(p.confirmed),U=si(p.counts,p.suppressions_added),v=p.rows.length,k=p.file.kind==="txt"?"Plain list (.txt)":`CSV, ${i.format}`;b(n,o`<section class="card imp-card imp-file" tabindex="-1" aria-label="The file chosen and its plan">
|
|
815
827
|
<div class="imp-file-row">
|
|
816
|
-
<div><strong class="imp-file-name">${p.file.name}</strong><div class="muted">${
|
|
828
|
+
<div><strong class="imp-file-name">${p.file.name}</strong><div class="muted">${k} · ${ii(p.file.rows)} · ${bp(p.file.addresses)}</div></div>
|
|
817
829
|
<button type="button" class="ghost" id="impReplace">Replace file</button>
|
|
818
830
|
</div>
|
|
819
|
-
${p.file.kind==="txt"?
|
|
831
|
+
${p.file.kind==="txt"?w(p.file.plain_list_status??i.plain_list.default):null}
|
|
820
832
|
</section>
|
|
821
833
|
<section class="card imp-card">
|
|
822
834
|
<h2>What will happen</h2>
|
|
823
|
-
${
|
|
835
|
+
${kl(V,Z,F)}
|
|
824
836
|
${U?o`<p class="imp-text">${U}</p>`:null}
|
|
825
|
-
${
|
|
826
|
-
${p.
|
|
837
|
+
${v>0?o`<div class="row imp-links"><button type="button" class="ghost" id="impNotImported">Download the ${ii(v)} not imported as subscribed (CSV)</button></div>`:null}
|
|
838
|
+
${p.workers_plan_advice?o`<p class="workers-plan" role="status">${p.workers_plan_advice}</p>`:null}
|
|
827
839
|
</section>
|
|
828
840
|
<section class="card imp-card imp-confirm">
|
|
829
841
|
<h2>Confirm</h2>
|
|
830
842
|
${p.sends_growing.length>0?o`<p class="imp-text">A scheduled post goes to the list as it stands when it sends, so this import adds to its audience. Acknowledge each one.</p>
|
|
831
|
-
${p.sends_growing.map(
|
|
843
|
+
${p.sends_growing.map(D=>o`<label class="imp-check"><input type="checkbox" data-ack="${D.id}"><span>“${D.subject||"Untitled"}”, sending ${W(D.fire_at)}, goes to ${D.gains.toLocaleString("en-US")} more ${D.gains===1?"person":"people"}.</span></label>`)}`:null}
|
|
832
844
|
${p.attestation?o`<label class="imp-check"><input type="checkbox" id="impAttest"><span>${p.attestation.text}</span></label>
|
|
833
845
|
<p class="field-hint imp-attest-hint">Only import people who asked to receive ${p.attestation.publication}. If you’re unsure about some, take them out of the file before importing.</p>`:null}
|
|
834
846
|
${p.attestation===null&&p.sends_growing.length===0?o`<p class="imp-text">Nobody is imported as a subscriber, so there’s nothing to attest.</p>`:null}
|
|
835
|
-
${
|
|
847
|
+
${R?xl(R):null}
|
|
836
848
|
<div class="actions imp-actions"><a class="ghost-link" href="#/subscribers">Cancel</a><button type="button" class="primary" id="impGo">Import</button></div>
|
|
837
|
-
</section>`);let
|
|
849
|
+
</section>`);let S=c("#impReplace",n);S.onclick=()=>s.click(),v>0&&(c("#impNotImported",n).onclick=()=>E(p.file.name,p.rows));let C=c("#impGo",n),L=I("[data-plain]",n),J=D=>{S.disabled=D;for(let se of L)se.disabled=D};for(let D of L)D.onclick=()=>{let se=D.dataset.plain;se!==p.file.plain_list_status&&(C.disabled=!0,J(!0),(async()=>{let K=await f(x,se);K&&!t.aborted&&(g(K,x),c(`[data-plain="${se}"]`,n).focus())})())};C.onclick=()=>M(p,x,C,J)}function w(p){return o`<div class="imp-plain">
|
|
838
850
|
<span class="imp-plain-label" id="impPlainLabel">These addresses are</span>
|
|
839
|
-
<span class="set-wtog" role="group" aria-labelledby="impPlainLabel">${
|
|
851
|
+
<span class="set-wtog" role="group" aria-labelledby="impPlainLabel">${pl.map(x=>o`<button type="button" class="wtog-btn" data-plain="${x}" aria-pressed="${String(x===p)}">${fp[x]}</button>`)}</span>
|
|
840
852
|
<p class="field-hint">A plain list doesn’t say how each person joined, so it’s recorded as unknown.</p>
|
|
841
|
-
</div>`}function
|
|
853
|
+
</div>`}function E(p,x){let R=p.replace(/\.(csv|txt)$/i,"");Et(Sl(x),`${R}-not-imported.csv`,"text/csv")}async function M(p,x,R,F){n.querySelector(".imp-act-error")?.remove();let V=I("[data-ack]",n),Z=n.querySelector("#impAttest");for(let U of V)if(!U.checked){A(U,"Tick this to acknowledge that this post will go to the people this import adds.");return}if(Z&&!Z.checked){A(Z,"Tick this to attest that everyone you\u2019re importing as subscribed opted in.");return}F(!0),await G(R,"Importing\u2026",async()=>{try{let U=await H("/api/imports",{method:"POST",json:{file:x,plain_list_status:p.file.plain_list_status??void 0,plan_hash:p.plan_hash,...p.attestation?{attestation:{version:p.attestation.version,accepted:!0}}:{},acknowledge_sends:p.sends_growing.map(v=>v.id)}});t.aborted||$(U,p)}catch(U){if(t.aborted)return;if(U instanceof ee&&U.code==="plan_stale"){let v=await f(x,p.file.plain_list_status);v&&!t.aborted&&(g(v,x,mp),c(".imp-act-error",n).focus());return}T(q(U)),F(!1)}})}function A(p,x){Me(p,x,{after:p.closest("label")??p}),p.focus()}function T(p){n.querySelector(".imp-act-error")?.remove();let x=document.createElement("div");b(x,xl(p)),c(".imp-actions",n).before(...x.childNodes)}function $(p,x){c("#impLede",e).hidden=!0;let R=p.import,F=As(R),V=ni(p.counts),Z=si(p.counts,p.suppressions_added),U=p.replayed?[]:wl(x.rows,p.rows,p.differed);b(n,o`<section class="card imp-card imp-done">
|
|
842
854
|
<h2 id="impDoneHead" tabindex="-1">${p.replayed?"This import was already done":"Import done"}</h2>
|
|
843
855
|
${p.replayed?o`<p class="imp-text">This plan was already imported, so nothing was written again. Here is its record.</p>`:null}
|
|
844
|
-
${
|
|
845
|
-
${
|
|
856
|
+
${kl(V,null,0)}
|
|
857
|
+
${Z?o`<p class="imp-text">${Z}</p>`:null}
|
|
846
858
|
<p class="imp-text imp-nobody">Nobody was emailed. To tell your readers about the move, write a post as usual.</p>
|
|
847
859
|
</section>
|
|
848
860
|
${U.length>0?o`<section class="card imp-card imp-differed">
|
|
849
861
|
<h2>Rows that turned out differently</h2>
|
|
850
|
-
<p class="imp-text">Your list changed between the plan and the import, so ${
|
|
851
|
-
<div class="table-wrap"><table class="imp-ref"><thead><tr><th>Row</th><th>Email</th><th>Planned</th><th>Applied</th></tr></thead><tbody>${U.map(
|
|
862
|
+
<p class="imp-text">Your list changed between the plan and the import, so ${ii(U.length)} had a different result. Each was applied to the list as it stood.</p>
|
|
863
|
+
<div class="table-wrap"><table class="imp-ref"><thead><tr><th>Row</th><th>Email</th><th>Planned</th><th>Applied</th></tr></thead><tbody>${U.map(v=>o`<tr><td>${String(v.row)}</td><td>${v.email}</td><td>${v.planned}</td><td>${v.applied}</td></tr>`)}</tbody></table></div>
|
|
852
864
|
</section>`:null}
|
|
853
865
|
<section class="card imp-card imp-record">
|
|
854
866
|
<h2>The import’s record</h2>
|
|
855
867
|
<dl class="imp-dl">
|
|
856
|
-
<dt>File</dt><dd>${
|
|
857
|
-
<dt>Fingerprint</dt><dd><code>${
|
|
858
|
-
<dt>Imported</dt><dd>${
|
|
859
|
-
<dt>Attestation</dt><dd>${
|
|
868
|
+
<dt>File</dt><dd>${R.file_name}</dd>
|
|
869
|
+
<dt>Fingerprint</dt><dd><code>${R.file_sha256.slice(0,16)}</code></dd>
|
|
870
|
+
<dt>Imported</dt><dd>${W(R.created_at)} by ${F}</dd>
|
|
871
|
+
<dt>Attestation</dt><dd>${R.attestation_version===null?"None needed: nobody was imported as a subscriber.":x.attestation?o`“${x.attestation.text}” <span class="muted">(version ${String(R.attestation_version)})</span>`:`Version ${R.attestation_version}`}</dd>
|
|
860
872
|
</dl>
|
|
861
873
|
<div class="actions imp-actions"><button type="button" class="ghost" id="impAnother">Import another file</button><button type="button" class="primary" id="impToList">See your subscribers</button></div>
|
|
862
|
-
</section>`),c("#impAnother",n).onclick=()=>{
|
|
863
|
-
${i.length>0?o`<p class="imp-kept-head">Kept as they are here</p><ul class="imp-outcomes imp-kept">${i.map(
|
|
874
|
+
</section>`),c("#impAnother",n).onclick=()=>{r(),c("#impChoose",n).focus()},c("#impToList",n).onclick=()=>{location.hash="#/subscribers"},c("#impDoneHead",n).focus()}r()}function xl(e){return o`<div class="field-error imp-act-error" role="alert" tabindex="-1"><span class="field-error-ico" aria-hidden="true">!</span><span>${e}</span></div>`}function hp(e){return e.name.trim().toLowerCase().endsWith(".txt")}function ii(e){return`${e.toLocaleString("en-US")} ${e===1?"row":"rows"}`}function bp(e){return`${e.toLocaleString("en-US")} ${e===1?"address":"addresses"}`}function kl(e,t,n){let s=e.filter(r=>!r.kept),i=e.filter(r=>r.kept),a=s.find(r=>r.outcome==="added_confirmed"||r.outcome==="confirmed_pending")?.outcome;return o`<ul class="imp-outcomes">${s.map(r=>o`<li data-outcome="${r.outcome}"><span>${r.text}</span>${r.outcome===a&&t&&n>0?o`<div class="field-hint imp-breakdown">Of the ${n.toLocaleString("en-US")} confirmed, by opt-in: ${t.optIn}. From: ${t.origin}.</div>`:null}</li>`)}</ul>
|
|
875
|
+
${i.length>0?o`<p class="imp-kept-head">Kept as they are here</p><ul class="imp-outcomes imp-kept">${i.map(r=>o`<li data-outcome="${r.outcome}"><span>${r.text}</span></li>`)}</ul>`:null}`}function gp(e){return e.suppression&&qe[e.suppression.reason]||"suppressed"}function Ps(e){return e.suppression?o` <span class="badge suppressed row-flag">${gp(e)}</span>`:null}function Ms(e){return e.suppression?o`<span class="badge ${e.status} held">${e.status}</span>`:ze(e.status)}function El(e){let t=e.lastIndexOf("@");return t>0?o`${e.slice(0,t)}<wbr>${e.slice(t)}`:o`${e}`}function An(e){return e.status==="unsubscribed"&&e.unsubscribe_cause==="erased"}function Is(e){return`#/subscribers/${e}`}function Ns(e,t,n){let s=[];return e.status!=="unsubscribed"&&s.push({label:st.unsubscribe.label,danger:!0,onClick:()=>Hr(e,t,n)}),e.suppression?e.suppression.reason==="manual"?s.push({label:st.unblock.label,onClick:()=>Pr(e,t,n)}):qe[e.suppression.reason]==="bounced"&&s.push({label:st.clear.label,danger:!0,onClick:()=>Ir(e,t,n)}):s.push({label:st.block.label,danger:!0,onClick:()=>Ar(e,t,n)}),An(e)||s.push({label:st.erase.label,danger:!0,onClick:()=>Nr(e,t,n)}),s}async function vp(){let e=[],t=null,n=Date.now();do{let s=t===null?"":`?after=${encodeURIComponent(t)}`,i=await H(`/api/subscribers/export${s}`);if(i.next!==null&&i.next===t)throw new Error("The export repeated a page. Try again.");e.push(...i.subscribers),n=i.exported_at,t=i.next}while(t!==null);return{rows:e,exportedAt:n}}async function _l(){let{rows:e,exportedAt:t}=await vp();return{name:`kestrel-subscribers-${new Date(t).toISOString().slice(0,10)}.csv`,text:Qo(e)}}var Tl=[{value:"confirmed",label:"Confirmed"},{value:"pending",label:"Pending"},{value:"unsubscribed",label:"Unsubscribed"}],ci=["confirmed","pending","unsubscribed","suppressed"];async function Ll(e,t,n){let s={status:"",search:"",suppressed:"",sort:"created_at",dir:"desc",offset:0,limit:50};e==="suppressed"?s.suppressed="only":e&&ci.includes(e)&&(s.status=e),b(t,o`
|
|
864
876
|
<div class="spread page-head"><h1>Subscribers</h1><span class="row"><button type="button" class="ghost" id="importSubs">Import</button><button type="button" class="ghost" id="exportSubs">Export</button><button class="primary" id="addSub">Add subscriber</button></span></div>
|
|
865
|
-
<div class="card sub-summary"><div class="sub-summary-head"><div id="subCounts" class="row muted" role="group" aria-label="Show">Loading…</div><button type="button" class="sub-legend-toggle" id="subLegendToggle" aria-expanded="false" aria-controls="subLegend">Legend</button></div>${
|
|
866
|
-
${
|
|
877
|
+
<div class="card sub-summary"><div class="sub-summary-head"><div id="subCounts" class="row muted" role="group" aria-label="Show">Loading…</div><button type="button" class="sub-legend-toggle" id="subLegendToggle" aria-expanded="false" aria-controls="subLegend">Legend</button></div><p class="workers-plan" id="subWorkersPlan" role="status" hidden></p>${$p()}</div>
|
|
878
|
+
${Ut({statuses:Tl,suppressible:!0,searchPlaceholder:"Search email\u2026"})}
|
|
867
879
|
<div id="subList" class="muted">Loading…</div>
|
|
868
|
-
<div id="subPager"></div>`);let i=c("#subList"),
|
|
880
|
+
<div id="subPager"></div>`);let i=c("#subList"),a=c("#subPager");async function r(){try{let u=await H(`/api/subscribers?${Ft(s)}`,{signal:n}),m=u.counts;wp(c("#subCounts"),m,s);let h=c("#subWorkersPlan");h.hidden=!u.workers_plan_advice,h.textContent=u.workers_plan_advice??"";let f=m.pending+m.confirmed+m.unsubscribed===0;xp(i,u.subscribers,s,r,n,f),nt(a,s,u.page,r)}catch(u){ne(i,q(u),r)}}Sp(t,s,r);let l=c("#subLegendToggle");l.onclick=()=>{let u=l.getAttribute("aria-expanded")!=="true";l.setAttribute("aria-expanded",String(u)),c("#subLegend").hidden=!u},c("#addSub").onclick=()=>hs(r,n),c("#importSubs").onclick=()=>{location.hash="#/subscribers/import"};let d=c("#exportSubs");d.onclick=()=>G(d,"Exporting\u2026",async()=>{try{let u=await _l();Et(u.text,u.name,"text/csv")}catch(u){O(q(u))}}),qt(t,s,r),r()}var yp=["confirmed","pending","unsubscribed","suppressed"];function li(e,t){return e.search?!1:t==="suppressed"?e.suppressed==="only"&&!e.status:e.status===t&&!e.suppressed}function wp(e,t,n){e.querySelector("[data-filter]")||b(e,o`${yp.map(s=>o`<button type="button" class="sub-count" data-filter="${s}" aria-pressed="false"></button>`)}`);for(let s of I("[data-filter]",e)){let i=s.dataset.filter;b(s,o`${i==="confirmed"?o`<strong>${t[i]}</strong>`:t[i]} ${i}`),s.setAttribute("aria-pressed",String(li(n,i)))}}function Sp(e,t,n){let s=c("#subCounts",e);s.onclick=i=>{let a=i.target instanceof Element?i.target.closest("[data-filter]"):null;if(!a)return;let r=a.dataset.filter,l=!li(t,r);t.status=l&&r!=="suppressed"?r:"",t.suppressed=l&&r==="suppressed"?"only":"",t.search="",t.offset=0,c(".lt-search",e).value="",c(".lt-status",e).value=t.status,c(".lt-suppressed",e).checked=t.suppressed==="only";for(let d of I("[data-filter]",s))d.setAttribute("aria-pressed",String(li(t,d.dataset.filter)));n()}}function ai(e,t){return o`<dt>${e}</dt><dd>${t}</dd>`}function $p(){let e=Object.keys(mn);return o`<div class="sub-legend" id="subLegend" role="region" aria-labelledby="subLegendToggle" hidden><div class="sub-legend-body">
|
|
869
881
|
<h2>Status</h2>
|
|
870
|
-
<dl>${
|
|
882
|
+
<dl>${Tl.map(({value:t})=>ai(ze(t),t==="unsubscribed"?`${go[t]} Why they left is on their page.`:go[t]))}</dl>
|
|
871
883
|
<h2>Suppressed</h2>
|
|
872
|
-
<p>${
|
|
873
|
-
<dl>${e.map(t=>
|
|
884
|
+
<p>${Cr} The red flag beside the address says why:</p>
|
|
885
|
+
<dl>${e.map(t=>ai(o`<span class="badge suppressed">${t}</span>`,mn[t]))}</dl>
|
|
874
886
|
<h2>What you can do</h2>
|
|
875
|
-
<dl>${Object.values(
|
|
876
|
-
</div></div>`}function
|
|
887
|
+
<dl>${Object.values(st).map(({label:t,when:n})=>ai(o`<strong class="sub-legend-act">${t}</strong>`,n))}</dl>
|
|
888
|
+
</div></div>`}function xp(e,t,n,s,i,a){if(!t.length){b(e,a?o`<p class="muted">No subscribers yet. <a href="#/subscribers/import">Import your list</a> from another service, or share your subscribe page.</p>`:o`<p class="muted">No subscribers match.</p>`);return}b(e,o`<div class="table-wrap subs-wrap"><table class="list-table subs-table stacks has-actions"><colgroup><col><col class="c-status"><col class="c-date"><col class="c-act"></colgroup><thead><tr>${Se("Email","email",n)}${Se("Status",null,n)}${Se("Joined","created_at",n)}<th></th></tr></thead><tbody>${t.map(r=>o`<tr class="clickable" data-id="${r.id}"><td><a class="sub-email" href="${Is(r.id)}">${El(r.email)}</a>${Ps(r)}</td><td>${Ms(r)}</td><td class="muted joined">${W(r.created_at)}</td><td class="act"><button class="icon" data-menu="${r.id}" aria-label="Subscriber actions" aria-haspopup="menu">⋯</button></td></tr>`)}</tbody></table></div>`),tt(e,n,s);for(let r of I("tr[data-id]",e))r.onclick=l=>{l.target instanceof Element&&l.target.closest("a, button")||(location.hash=Is(r.dataset.id??""))};for(let r of I("[data-menu]",e))r.onclick=l=>{l.stopPropagation();let d=t.find(m=>m.id===r.dataset.menu);if(!d)return;let u=[{label:"View subscriber",onClick:()=>{location.hash=Is(d.id)}},...Ns(d,s,i)];Wt(r,u,i)}}var Bs=o`<div class="editor-head"><a href="#/subscribers" class="back">← Subscribers</a></div>`;async function Rl(e,t,n){b(t,o`${Bs}<p class="muted">Loading…</p>`);let s=`/api/subscribers/${encodeURIComponent(e)}`;async function i(r){let l,d,u;try{let m=H(s,{signal:n}),h=m.then(({subscriber:E})=>E.import_id===null?null:H(`/api/imports/${encodeURIComponent(E.import_id)}`,{signal:n}).then(M=>M.import,()=>null)),[f,g,w]=await Promise.all([m,H(`${s}/deliveries`,{signal:n}),h]);l=f.subscriber,d=g,u=w}catch(m){let h=document.createElement("div");b(t,Bs),t.append(h),ne(h,q(m),()=>{b(t,o`${Bs}<p class="muted">Loading…</p>`),i(!1)});return}n.aborted||(a(l,d,u),r&&c("h1.sub-title",t).focus())}function a(r,l,d){let u=Ns(r,()=>{i(!0)},n),m=An(r);b(t,o`${Bs}
|
|
889
|
+
<div class="spread page-head sub-page-head">
|
|
890
|
+
<div class="sub-page-title"><h1 class="sub-title" tabindex="-1">${r.email}</h1><span class="sub-pills">${Ms(r)}${Ps(r)}</span></div>
|
|
891
|
+
<span class="row sub-acts">${u.map((g,w)=>o`<button type="button" class="${g.danger?"danger-subtle":""}" data-act="${w}">${g.label}…</button>`)}</span>
|
|
892
|
+
</div>
|
|
893
|
+
<p class="sub-receives${r.receives_posts?" yes":""}">${kp(r)}</p>
|
|
894
|
+
${m?null:Ep(r,d)}
|
|
895
|
+
${_p(r)}
|
|
896
|
+
${r.unsubscribe_cause?Rp(r):null}
|
|
897
|
+
<section class="sub-sec sub-sends" aria-labelledby="subSendsTitle">
|
|
898
|
+
<h2 class="rec-part-title" id="subSendsTitle">Sends</h2>
|
|
899
|
+
<div id="subSends"></div>
|
|
900
|
+
<div id="subMore"></div>
|
|
901
|
+
</section>`);for(let g of I("[data-act]",t))g.onclick=()=>u[Number(g.dataset.act)]?.onClick();let h=t.querySelector("#subComplaint");Cp(t,s,l,m,n,g=>{let w=g.find(E=>E.delivery.event==="complained");h?.hidden&&w&&(b(h,o`The report was about <a href="#/sent/${w.send.id}">${w.send.subject||"an untitled post"}</a>.`),h.hidden=!1)})}await i(!1)}function kp(e){if(e.receives_posts)return"Receives your posts.";let t=[];return e.status==="pending"?t.push("they haven\u2019t confirmed"):e.status==="unsubscribed"&&t.push(`they left the list on ${W(e.unsubscribed_at)}`),e.suppression&&t.push(qe[e.suppression.reason]==="bounced"?"the address bounced":"the address is blocked"),t.length>0?`Not sent your posts: ${t.join(", and ")}.`:"Not sent your posts."}function je(e,t){return o`<dt>${e}</dt><dd>${t}</dd>`}function Ep(e,t){let n=bl(e.consent_evidence),s=hl(e)??(e.status==="pending"?"Signed up here, not confirmed yet":"Signed up here, never confirmed");return o`<section class="card sub-sec" aria-labelledby="subJoinedTitle">
|
|
902
|
+
<h2 class="rec-part-title" id="subJoinedTitle">How they joined</h2>
|
|
903
|
+
<dl class="sub-dl">
|
|
904
|
+
${je("How they joined",s)}
|
|
905
|
+
${e.import_id!==null?o`${je("Origin",Cn(e.origin)??"Unknown")}
|
|
906
|
+
${je("Opt-in",Hn(e.opt_in))}
|
|
907
|
+
${t?o`${je("Import",o`<code>${t.file_name}</code>`)}
|
|
908
|
+
${je("Imported",`${W(t.created_at)}, by ${As(t)}`)}
|
|
909
|
+
${je("Attestation",t.attestation_version===null?"None: the import confirmed no one":`Version ${t.attestation_version}`)}`:null}`:null}
|
|
910
|
+
${je("Joined",W(e.created_at))}
|
|
911
|
+
${je("Confirmed",W(e.confirmed_at))}
|
|
912
|
+
${je("Evidence",n?o`<pre class="sub-evidence">${n}</pre>`:"None recorded")}
|
|
913
|
+
</dl>
|
|
914
|
+
${e.import_id!==null?o`<p class="sub-note">The origin, opt-in, confirmation date, and evidence are what the imported file said, not something Kestrel recorded itself.</p>`:null}
|
|
915
|
+
</section>`}function _p(e){let t=e.suppression,n;if(!t)n=o`<p class="muted">None.</p>`;else{let s=qe[t.reason];n=o`<p class="sub-sup-line"><span class="badge suppressed">${s}</span> <span class="muted">since ${W(t.created_at)}</span></p>
|
|
916
|
+
<p class="sub-meaning">${Lp(e,t.reason)}</p>
|
|
917
|
+
${t.detail?o`<dl class="sub-dl">${je("Detail",t.detail)}</dl>`:null}`}return o`<section class="card sub-sec" aria-labelledby="subSupTitle">
|
|
918
|
+
<h2 class="rec-part-title" id="subSupTitle">Suppression</h2>
|
|
919
|
+
${n}
|
|
920
|
+
</section>`}var Tp={reader:"They had unsubscribed themselves, from the link or the button in your email.",publisher:"You had unsubscribed them, when they asked you to.",complaint:"They had reported your email as spam.",erased:"You had erased them at their request.",import:"The list you imported said they had left."};function Lp(e,t){switch(t){case"bounce":return"Your email provider reported that mail to this address failed. You can clear it.";case"import_bounce":return"Mail to this address failed at the service your list was imported from. You can clear it.";default:return e.import_id===null&&!An(e)?"You blocked this address. You can unblock it.":mn[qe[t]]}}function Rp(e){let t=e.unsubscribe_cause;if(!t)return null;let n=e.status!=="unsubscribed";return o`<section class="card sub-sec" aria-labelledby="subLeftTitle">
|
|
921
|
+
<h2 class="rec-part-title" id="subLeftTitle">${n?"Left before":"Why they left"}</h2>
|
|
922
|
+
<p class="sub-left-line"><span class="muted">Left ${W(e.unsubscribed_at)}</span></p>
|
|
923
|
+
<p class="sub-meaning">${n?Tp[t]:Rr[t]}</p>
|
|
924
|
+
${t==="complaint"?o`<p class="sub-meaning" id="subComplaint" hidden></p>`:null}
|
|
925
|
+
${e.status==="pending"?o`<p class="sub-meaning">They signed up again, and haven’t confirmed yet.</p>`:null}
|
|
926
|
+
</section>`}function Cp(e,t,n,s,i,a){let r=c("#subSends",e),l=c("#subMore",e);if(n.deliveries.length===0){b(r,s?o`<p class="muted">Their past sends show an erased recipient in place of the address, so none is listed here.</p>`:o`<p class="muted">Not in any send yet.</p>`);return}b(r,o`<div class="table-wrap"><table class="list-table sub-sends-table"><colgroup><col><col class="c-out"><col><col class="c-date"></colgroup><thead><tr><th>Send</th><th class="c-out">Outcome</th><th>Detail</th><th class="c-date">When</th></tr></thead><tbody></tbody></table></div>`);let d=c("tbody",r),u=h=>{a(h.deliveries);let f=document.createElement("tbody");b(f,o`${h.deliveries.map(Ap)}`),d.append(...f.childNodes),m(h.next)},m=h=>{if(!h){b(l,o``);return}b(l,o`<button type="button" class="sub-more">Show more</button>`);let f=c(".sub-more",l);f.onclick=()=>G(f,"Loading\u2026",async()=>{try{u(await H(`${t}/deliveries?after=${encodeURIComponent(h)}`,{signal:i}))}catch(g){ne(l,q(g),()=>m(h))}})};u(n)}var Hp="Left out: unsubscribed or suppressed before it went";function Ap({send:e,delivery:t}){let n=Es(t),s=t.error||t.event_detail||(t.event===null&&t.status==="skipped"?Hp:"");return o`<tr><td class="sub-send"><a href="#/sent/${e.id}">${e.subject||o`<em>untitled</em>`}</a><div class="muted">${W(e.fire_at)}</div></td><td><span class="rec-out"><span class="rec-sw sw-${n.sw}"></span>${n.label}</span></td><td class="muted">${s||"\u2014"}</td><td class="muted">${t.event_at?W(t.event_at):"\u2014"}</td></tr>`}var Pp="You have unsaved changes. Leave without saving?",di="";function ui(){let e=location.hash||"#/dashboard";(e==="#/reference"||e.startsWith("#/reference/"))&&(e=`#/api-reference${e.slice(11)}`,history.replaceState(history.state,"",e)),di=e;let[,t,n,s]=(ao(e.slice(1))?e:"#/dashboard").split("/");document.body.classList.toggle("editor-mode",t==="edit");let i=t==="web"?pa(n,s):null;document.body.classList.toggle("web-mode",i!==null);let a=t==="docs"||t==="api-reference";document.body.classList.toggle("tool-mode",a);for(let r of document.querySelectorAll(".sidebar a[data-view]"))r.dataset.view===t?r.setAttribute("aria-current","page"):r.removeAttribute("aria-current");return nn(!1),t==="edit"&&n?Q((r,l)=>_o(n,r,l)):i!==null?Q((r,l)=>Eo(i,r,l)):t==="drafts"?Q(bo):t==="subscribers"&&n==="import"?Q(ri):t==="subscribers"&&n&&!ci.includes(n)?Q((r,l)=>Rl(n,r,l)):t==="subscribers"?Q((r,l)=>Ll(n,r,l)):t==="sent"?n?Q((r,l)=>Oo(n,r,l)):Q(Bo):t==="template"?Q(Wo):t==="settings"?Q(zo):t==="api-reference"?Q((r,l)=>No(r,l,n)):t==="docs"?Q((r,l)=>Lo(n,r,l,s)):Q(yo)}function Cl(){let e=br(location.search);if(e){let t=new URL(location.href);t.search="",t.hash=e,history.replaceState(null,"",t)}}function Hl(){window.addEventListener("hashchange",()=>{if(location.hash!==di){if(Bn()?.beforeLeave?.()==="confirm"&&!confirm(Pp)){location.hash=di;return}ui()}}),window.addEventListener("beforeunload",e=>{Bn()?.dirty?.()&&(e.preventDefault(),e.returnValue="")}),window.addEventListener("keydown",e=>{let t=Bn()?.manualSave;(e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="s"&&t&&(e.preventDefault(),t())})}async function Mp(){yi(),Lr(),hr(),Hl();let e=await Ti();if(e){X.session=e,document.body.classList.remove("signed-out"),_i();try{X.appConfig=await H("/api/settings")}catch{}return Wn(),Cl(),ui()}}Mp();})();
|
|
877
927
|
//# sourceMappingURL=app.js.map
|