Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

595 lines · 25.5 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC } from "hono/jsx";
4import { requireAccount, type AppContext } from "../auth/principal.js";
5import { turnstileMode } from "../auth/signup.js";
6import { formStamp } from "../lib/early-access.js";
7import type { Account, Directory } from "../lib/directory.js";
8import {
9 CONFIRM_HOURS,
10 ContactMessages,
11 MAX_MESSAGE,
12 MAX_NAME,
13 MAX_NOTE,
14 MESSAGE_STATES,
15 TOPICS,
16 UNCONFIRMED_DAYS,
17 changeMessage,
18 confirmMessage,
19 deleteMessage,
20 isTopic,
21 submitMessage,
22 topicLabel,
23 validateMessage,
24 type ContactMessage,
25 type MessageState,
26} from "../lib/contact.js";
27import { HttpError } from "../lib/util.js";
28import { ICONS } from "./assets.js";
29import { SettingsNav } from "./account.js";
30import { csrfToken } from "./csrf.js";
31import { CONFIRM_SCRIPT, Protections, guard } from "./early-access.js";
32import { ComputePausedBanner } from "./kill-switches.js";
33import { KSelect } from "./kumo.js";
34import { Form, Pager, Time, act, flash, pageNum, post, principalAccount, prm, qs, render, statusOf, type WebApp } from "./ui.js";
35
36/**
37 * The contact form in the web UI (lib/contact.ts): /contact for anyone, signed in or not, the page
38 * its confirmation email links to, and the **Contact messages** page of site administration, where
39 * messages are read, marked handled or spam, noted and deleted.
40 */
41
42/** Largest message form, in bytes: the message's cap with room for percent-encoded UTF-8. */
43const MAX_FORM_BYTES = 64 * 1024;
44const BASE = "/settings/admin/contact";
45
46const TURNSTILE_MISSING = "This instance has no Turnstile configured (TURNSTILE_SITE_KEY and TURNSTILE_SECRET), so its contact form takes messages only from people who are signed in.";
47
48export const STATE_TEXT: Record<MessageState, string> = { new: "New", handled: "Handled", spam: "Spam", unconfirmed: "Unconfirmed" };
49const STATE_BADGE: Record<MessageState, string> = { new: "badge blue", handled: "badge green", spam: "badge red", unconfirmed: "badge" };
50const StateBadge: FC<{ state: MessageState }> = ({ state }) => <span class={STATE_BADGE[state]}>{STATE_TEXT[state]}</span>;
51
52/** Who writes as themselves: an active person signed in. Anyone else goes through Turnstile and confirms by email. */
53const senderOf = (c: AppContext): Account | null => {
54 const a = principalAccount(c);
55 return a && a.kind === "user" && a.status === "active" ? a : null;
56};
57
58interface FormValues {
59 email?: string;
60 name?: string;
61 topic?: string;
62 message?: string;
63}
64
65// ---------------------------------------------------------------- the form
66
67const Way: FC<{ icon: string; title: string; children: Child }> = ({ icon, title, children }) => (
68 <li>
69 <span class="ic">{raw(icon)}</span>
70 <span>
71 <b>{title}</b>
72 <span class="muted">{children}</span>
73 </span>
74 </li>
75);
76
77async function contactPage(c: AppContext, o: { values?: FormValues; error?: string; status?: number } = {}): Promise<Response> {
78 const me = senderOf(c);
79 const mode = turnstileMode(c.env);
80 const siteKey = !me && mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
81 const closed = !me && mode === "missing";
82 const v = o.values ?? {};
83 const stamp = me || closed ? "" : await formStamp(c.env);
84 const topic = v.topic ?? (isTopic(c.req.query("topic") ?? "") ? c.req.query("topic") : "");
85 const origin = c.env.PUBLIC_URL.replace(/\/$/, "");
86 return render(
87 c,
88 {
89 title: "Contact us",
90 seoTitle: "Contact the Diffinery team",
91 status: o.status,
92 turnstile: !!siteKey,
93 description: "Write to the people who run this Diffinery instance: questions, help with your account, running your own Diffinery, partnerships, press and privacy requests.",
94 jsonLd: [{ "@type": "ContactPage", "@id": `${origin}/contact#webpage`, url: `${origin}/contact`, name: "Contact the Diffinery team", isPartOf: { "@id": `${origin}/#website` }, about: { "@id": `${origin}/#organization` }, inLanguage: "en" }],
95 },
96 <div class="ea ct">
97 <section class="ea-head">
98 <span class="ea-eyebrow">{raw(ICONS.envelope)} Contact</span>
99 <h1>Contact us</h1>
100 <p class="ea-lede">Questions, help with your account, running your own Diffinery, partnerships or press: write to the people who run this instance. A person reads every message and replies by email.</p>
101 </section>
102 <section class="ea-more">
103 <ul class="ea-points">
104 <Way icon={ICONS.shield} title="Found a security problem?">
105 Report it privately with the <a href="/security/report">security report form</a> instead, so only the site administrators see it.
106 </Way>
107 <Way icon={ICONS.zap} title="Is something down?">
108 The <a href="/status">status page</a> shows incidents as they happen.
109 </Way>
110 <Way icon={ICONS.code} title="Want to know how something works?">
111 The <a href="/docs">docs</a> answer most questions, and the <a href="/faq">FAQ</a> the common ones.
112 </Way>
113 </ul>
114 {me ? null : (
115 <ol class="ea-steps">
116 <li>Send this form.</li>
117 <li>Confirm your email address with the link we send you.</li>
118 <li>Your message goes to the team, who reply to that address.</li>
119 </ol>
120 )}
121 </section>
122 <div class="box ea-card">
123 <div class="body">
124 {closed ? (
125 <p class="m0">
126 {TURNSTILE_MISSING} <a href="/login?next=%2Fcontact">Sign in</a> to write to them.
127 </p>
128 ) : (
129 <Form action="/contact" t={csrfToken(c)}>
130 <h2>Send us a message</h2>
131 <p class="muted small">{me ? `Signed in as ${me.login}. We'll reply to ${me.email ?? "your account's email address"}.` : "Every field is required."}</p>
132 {o.error ? (
133 <div class="flash error" role="alert">
134 {o.error}
135 </div>
136 ) : null}
137 <div class="field">
138 <label for="ct-name">Name</label>
139 <input id="ct-name" name="name" type="text" required maxlength={MAX_NAME} value={v.name ?? (me ? me.name || "" : undefined)} autocomplete="name" />
140 </div>
141 {me ? null : (
142 <div class="field">
143 <label for="ct-email">Email</label>
144 <input id="ct-email" name="email" type="email" required maxlength={254} value={v.email} autocomplete="email" placeholder="you@example.com" />
145 </div>
146 )}
147 <div class="field">
148 <label>Topic</label>
149 <KSelect name="topic" label="Topic" block value={topic || undefined} placeholder="Choose a topic" options={TOPICS.map((t) => ({ value: t.value, label: t.label }))} />
150 </div>
151 <div class="field">
152 <label for="ct-message">Message</label>
153 <textarea id="ct-message" name="message" required maxlength={MAX_MESSAGE} style="min-height:160px">
154 {v.message ?? ""}
155 </textarea>
156 </div>
157 {me ? null : <Protections stamp={stamp} siteKey={siteKey} />}
158 <button class="btn primary block">Send message</button>
159 <p class="ea-privacy">
160 Only the site administrators can read your message. We use it and your address only to reply, and delete it within a year of handling it. See <a href="/privacy">Privacy</a>.
161 </p>
162 </Form>
163 )}
164 </div>
165 </div>
166 </div>,
167 );
168}
169
170/** What someone who isn't signed in sees after sending, whatever happened to the message. */
171function checkInboxPage(c: AppContext, email: string): Response | Promise<Response> {
172 return render(
173 c,
174 { title: "Check your inbox" },
175 <div class="ea-done">
176 <span class="ic">{raw(ICONS.envelope)}</span>
177 <h1>Check your inbox</h1>
178 <p>
179 We've sent <b>{email}</b> a link to confirm your message. Open it within {CONFIRM_HOURS} hours and your message goes to the team.
180 </p>
181 <p class="muted">Nothing there? Check your spam folder. Messages that are never confirmed are deleted after {UNCONFIRMED_DAYS} days, unread.</p>
182 <p class="row" style="justify-content:center">
183 <a class="btn" href="/">
184 Back to the home page
185 </a>
186 <a class="btn" href="/docs">
187 Read the docs meanwhile
188 </a>
189 </p>
190 </div>,
191 );
192}
193
194/** What a signed-in person sees after sending. */
195function sentPage(c: AppContext, email: string): Response | Promise<Response> {
196 return render(
197 c,
198 { title: "Message sent" },
199 <div class="ea-done">
200 <span class="ic">{raw(ICONS.check)}</span>
201 <h1>Message sent</h1>
202 <p>
203 Thanks for writing to us. The team reads every message and replies to <b>{email}</b>. We've emailed you a copy.
204 </p>
205 <p class="row" style="justify-content:center">
206 <a class="btn" href="/">
207 Back to your dashboard
208 </a>
209 </p>
210 </div>,
211 );
212}
213
214/** Runs on the confirmation page: the early access form's, for this form. */
215const SCRIPT = CONFIRM_SCRIPT.replace('"ea-confirm"', '"ct-confirm"').replace('"ea-confirm-status"', '"ct-confirm-status"');
216
217function confirmPage(c: AppContext): Response | Promise<Response> {
218 return render(
219 c,
220 { title: "Confirm your message", script: SCRIPT },
221 <div class="ea-done">
222 <span class="ic">{raw(ICONS.envelope)}</span>
223 <h1>Confirm your message</h1>
224 <p class="muted" id="ct-confirm-status" aria-live="polite">
225 Checking your link…
226 </p>
227 <noscript>
228 <p class="muted">Turn on JavaScript for this page to confirm your message: the link's code stays in your browser until you press the button.</p>
229 </noscript>
230 <Form action="/contact/confirm" t={csrfToken(c)} id="ct-confirm" style="display:none">
231 <input type="hidden" name="token" value="" />
232 <p>Press the button to confirm that you wrote this message from this email address. It then goes to the team.</p>
233 <button class="btn primary">Confirm my message</button>
234 </Form>
235 </div>,
236 );
237}
238
239function confirmedPage(c: AppContext, m: ContactMessage | null): Response | Promise<Response> {
240 if (!m)
241 return render(
242 c,
243 { title: "This link doesn't work", status: 410 },
244 <div class="ea-done">
245 <span class="ic">{raw(ICONS.x)}</span>
246 <h1>This link doesn't work any more</h1>
247 <p class="muted">Confirmation links work once, for {CONFIRM_HOURS} hours. If you already confirmed, your message is with the team and there's nothing else to do.</p>
248 <p>
249 <a class="btn primary" href="/contact">
250 Write a new message
251 </a>
252 </p>
253 </div>,
254 );
255 return render(
256 c,
257 { title: "Message sent" },
258 <div class="ea-done">
259 <span class="ic">{raw(ICONS.check)}</span>
260 <h1>Your message is with the team</h1>
261 <p>
262 Thanks, <b>{m.name}</b>. A person reads every message and replies to <b>{m.email}</b>.
263 </p>
264 <p class="row" style="justify-content:center">
265 <a class="btn" href="/">
266 Back to the home page
267 </a>
268 <a class="btn" href="/docs">
269 Read the docs meanwhile
270 </a>
271 </p>
272 </div>,
273 );
274}
275
276// ---------------------------------------------------------------- site administration
277
278/** The initial on a message's avatar: its sender's name's first letter. */
279const initial = (m: Pick<ContactMessage, "name" | "email">) => (m.name || m.email).trim().charAt(0).toUpperCase() || "?";
280
281const Actions: FC<{ m: ContactMessage; t: string; back: string }> = ({ m, t, back }) => {
282 const button = (state: "new" | "handled" | "spam", label: Child, cls = "btn sm") => (
283 <Form action={`${BASE}/${m.id}/state`} t={t} class="inline">
284 <input type="hidden" name="state" value={state} />
285 <input type="hidden" name="back" value={back} />
286 <button class={cls}>{label}</button>
287 </Form>
288 );
289 return (
290 <>
291 {m.state === "new" || m.state === "unconfirmed" ? button("handled", <>{raw(ICONS.check)} Mark handled</>, "btn sm primary") : null}{" "}
292 {m.state !== "spam" ? button("spam", "Spam") : null} {m.state === "handled" || m.state === "spam" ? button("new", "Reopen") : null}
293 </>
294 );
295};
296
297type Row = ContactMessage & { account_login: string | null };
298
299/** Messages with the login of an active account that uses the address, if any (a signed-in sender's own). */
300async function withAccounts(db: D1Database, rows: ContactMessage[]): Promise<Row[]> {
301 if (!rows.length) return [];
302 const emails = [...new Set(rows.map((r) => r.email.toLowerCase()))];
303 const found = (
304 await db
305 .prepare(`SELECT lower(email) AS email, login FROM accounts WHERE kind = 'user' AND status = 'active' AND lower(email) IN (${emails.map(() => "?").join(", ")})`)
306 .bind(...emails)
307 .all<{ email: string; login: string }>()
308 ).results;
309 const by = new Map(found.map((f) => [f.email, f.login]));
310 return rows.map((r) => ({ ...r, account_login: r.sender_login ?? by.get(r.email.toLowerCase()) ?? null }));
311}
312
313/** A reply in the site administrator's own mail app, to the sender, quoting the topic. */
314const mailto = (m: ContactMessage) => `mailto:${encodeURIComponent(m.email).replace(/%40/g, "@")}?subject=${encodeURIComponent(`Re: ${topicLabel(m.topic)}`)}`;
315
316const backTo = (v: string): string => (v.startsWith(BASE) && !v.startsWith("//") && !/[\r\n]/.test(v) ? v : BASE);
317
318export function registerContact(web: WebApp): void {
319 // ------------------------------------------------------------ the form
320
321 web.get("/contact", (c) => contactPage(c));
322 web.get("/contact/", (c) => c.redirect("/contact", 301));
323
324 post(
325 web,
326 "/contact",
327 async (c, f) => {
328 const values: FormValues = { email: f.s("email").slice(0, 254), name: f.s("name").slice(0, MAX_NAME * 2), topic: f.s("topic").slice(0, 40), message: f.s("message").slice(0, MAX_MESSAGE * 2) };
329 try {
330 const me = senderOf(c);
331 // A filled honeypot or a form sent faster than a person types: answer as if it worked, keep nothing.
332 if (!me && (await guard(c, f, TURNSTILE_MISSING)) === "silent") return checkInboxPage(c, values.email?.trim() || "your address");
333 const input = validateMessage(values, !!me);
334 await submitMessage(c.env, (e) => c.get("dir").audit(e), input, me, c.req.header("cf-connecting-ip") ?? null);
335 return me ? sentPage(c, me.email ?? "your address") : checkInboxPage(c, input.email!);
336 } catch (e) {
337 const s = statusOf(e);
338 if (s.status >= 500 && s.status !== 503) throw e;
339 return contactPage(c, { values, error: s.message, status: s.status });
340 }
341 },
342 { maxBytes: MAX_FORM_BYTES },
343 );
344
345 web.get("/contact/confirm", (c) => confirmPage(c));
346
347 post(
348 web,
349 "/contact/confirm",
350 async (c, f) => confirmedPage(c, await confirmMessage(c.env, (e) => c.get("dir").audit(e), f.s("token").slice(0, 200))),
351 { maxBytes: 4096 },
352 );
353
354 // ------------------------------------------------------------ site administration: Contact messages
355
356 /** Messages don't exist for anyone but site administrators: 404, not 403. */
357 const requireSiteAdmin = (c: AppContext): Account => {
358 const me = requireAccount(c);
359 if (!me.site_admin || me.kind !== "user") throw new HttpError(404, "not found");
360 return me;
361 };
362
363 const page = (c: AppContext, me: Account, title: string, body: Child) =>
364 render(
365 c,
366 { title },
367 <div class="grid left">
368 <SettingsNav me={me} tab="contact" db={c.env.DB} />
369 <div>
370 <ComputePausedBanner env={c.env} />
371 {body}
372 </div>
373 </div>,
374 );
375
376 const PER_PAGE = 50;
377
378 web.get(BASE, async (c) => {
379 const me = requireSiteAdmin(c);
380 const qState = c.req.query("state");
381 const state: MessageState | "all" = qState === "all" ? "all" : MESSAGE_STATES.includes(qState as MessageState) ? (qState as MessageState) : "new";
382 const q = (c.req.query("q") ?? "").trim().slice(0, 100);
383 const pg = pageNum(c);
384 const store = new ContactMessages(c.env.DB);
385 const [list, counts] = await Promise.all([store.list({ state: state === "all" ? undefined : state, q, limit: PER_PAGE, offset: (pg - 1) * PER_PAGE }), store.counts()]);
386 const rows = await withAccounts(c.env.DB, list.items);
387 const total = counts.new + counts.handled + counts.spam + counts.unconfirmed;
388 const here = `${BASE}${qs({ state: state === "new" ? undefined : state, q, page: pg > 1 ? pg : undefined })}`;
389 const t = csrfToken(c);
390 const tab = (s: MessageState | "all", label: string, n: number) => (
391 <a href={`${BASE}${qs({ state: s === "new" ? undefined : s, q })}`} class={state === s ? "on" : ""}>
392 {label} <span class="n">{n}</span>
393 </a>
394 );
395 return page(
396 c,
397 me,
398 "Contact messages",
399 <>
400 <h1>Contact messages</h1>
401 <p class="muted">
402 Messages from <a href="/contact">/contact</a>. A message from someone who isn't signed in arrives once its address follows the emailed link. Reply from your own mailbox (the notification email's Reply-To is the sender), then mark it handled. Every change is in the <a href="/settings/audit">audit log</a>.
403 </p>
404 <nav class="tabs mb" aria-label="Messages by state">
405 {tab("new", "New", counts.new)}
406 {tab("handled", "Handled", counts.handled)}
407 {tab("spam", "Spam", counts.spam)}
408 {tab("unconfirmed", "Unconfirmed", counts.unconfirmed)}
409 {tab("all", "All", total)}
410 </nav>
411 <form method="get" action={BASE} class="row mb" role="search">
412 {state === "new" ? null : <input type="hidden" name="state" value={state} />}
413 <input type="search" name="q" value={q} maxlength={100} placeholder="Search by email, name or message" aria-label="Search messages" class="grow" style="max-width:420px" />
414 <button class="btn">{raw(ICONS.search)} Search</button>
415 {q ? (
416 <a class="btn" href={`${BASE}${qs({ state: state === "new" ? undefined : state })}`}>
417 Clear
418 </a>
419 ) : null}
420 </form>
421 <div class="box">
422 {rows.length ? (
423 <ul class="list ea-list">
424 {rows.map((m) => (
425 <li>
426 <span class="ea-av" aria-hidden="true">
427 {initial(m)}
428 </span>
429 <div class="grow" style="min-width:0">
430 <div class="row" style="gap:6px;flex-wrap:wrap">
431 <a class="title" href={`${BASE}/${m.id}`}>
432 {m.name}
433 </a>
434 <span class="muted small">{m.email}</span>
435 <StateBadge state={m.state} />
436 {m.account_login ? <span class="badge amber">{m.sender_login ? "signed in" : "has an account"}: {m.account_login}</span> : null}
437 </div>
438 <div class="meta">
439 {topicLabel(m.topic)} · sent <Time at={m.created_at} />
440 {m.handled_at && m.updated_by ? (
441 <>
442 {" "}
443 · {m.state === "spam" ? "marked spam" : "handled"} by {m.updated_by} <Time at={m.handled_at} />
444 </>
445 ) : null}
446 </div>
447 <div class="ea-use">{m.message.replace(/\s+/g, " ")}</div>
448 </div>
449 <div class="ea-actions nowrap">
450 <Actions m={m} t={t} back={here} />
451 </div>
452 </li>
453 ))}
454 </ul>
455 ) : (
456 <div class="empty">{q ? "No messages match your search." : state === "new" ? "No new messages. Messages show up here once their address confirms." : state === "all" ? "No contact messages yet." : `No ${STATE_TEXT[state].toLowerCase()} messages.`}</div>
457 )}
458 </div>
459 <Pager page={pg} hasNext={list.more} href={(p) => `${BASE}${qs({ state: state === "new" ? undefined : state, q, page: p > 1 ? p : undefined })}`} />
460 </>,
461 );
462 });
463
464 web.get(`${BASE}/:id`, async (c) => {
465 const me = requireSiteAdmin(c);
466 const found = await new ContactMessages(c.env.DB).get(prm(c, "id"));
467 if (!found) throw new HttpError(404, "no contact message with that id");
468 const [m] = (await withAccounts(c.env.DB, [found])) as [Row];
469 const t = csrfToken(c);
470 const here = `${BASE}/${m.id}`;
471 return page(
472 c,
473 me,
474 `Contact: ${m.name}`,
475 <>
476 <p class="small">
477 <a href={BASE}>← Contact messages</a>
478 </p>
479 <div class="row mb" style="gap:12px;align-items:center;flex-wrap:wrap">
480 <span class="ea-av lg" aria-hidden="true">
481 {initial(m)}
482 </span>
483 <div class="grow" style="min-width:0">
484 <h1 class="m0" style="overflow-wrap:anywhere">{m.name}</h1>
485 <div class="row" style="gap:6px;margin-top:4px;flex-wrap:wrap">
486 <StateBadge state={m.state} />
487 <span class="badge">{topicLabel(m.topic)}</span>
488 </div>
489 </div>
490 <div class="ea-actions">
491 <a class="btn sm" href={mailto(m)}>
492 {raw(ICONS.envelope)} Reply by email
493 </a>
494 <Actions m={m} t={t} back={here} />
495 </div>
496 </div>
497 <h2>Message</h2>
498 <div class="box">
499 {/* Plain text exactly as sent: never rendered as Markdown or HTML. */}
500 <pre class="body m0" style="white-space:pre-wrap;overflow-wrap:anywhere;font-family:var(--sans);font-size:14px">
501 {m.message}
502 </pre>
503 </div>
504 <div class="box">
505 <div class="body">
506 <dl class="kv m0">
507 <dt>From</dt>
508 <dd style="overflow-wrap:anywhere">
509 {m.name} &lt;<a href={mailto(m)}>{m.email}</a>&gt;
510 </dd>
511 <dt>Account</dt>
512 <dd>
513 {m.account_login ? (
514 <>
515 <a href={`/${m.account_login}`}>{m.account_login}</a> <span class="muted small">{m.sender_login ? "sent it signed in" : "uses this address"}</span>
516 </>
517 ) : (
518 <span class="muted">none uses this address</span>
519 )}
520 </dd>
521 <dt>Topic</dt>
522 <dd>{topicLabel(m.topic)}</dd>
523 <dt>Sent</dt>
524 <dd>
525 <Time at={m.created_at} /> <span class="muted small">{m.created_at}</span>
526 </dd>
527 <dt>Confirmed</dt>
528 <dd>{m.confirmed_at ? m.sender_login ? <span class="muted">signed in, so no confirmation was needed</span> : <Time at={m.confirmed_at} /> : <span class="muted">not yet: the address hasn't followed the emailed link</span>}</dd>
529 {m.handled_at && m.updated_by ? (
530 <>
531 <dt>{m.state === "spam" ? "Marked spam" : "Handled"}</dt>
532 <dd>
533 by {m.updated_by} <Time at={m.handled_at} />
534 </dd>
535 </>
536 ) : null}
537 <dt>Reference</dt>
538 <dd class="mono">{m.id}</dd>
539 </dl>
540 </div>
541 </div>
542 <h2>Note</h2>
543 <Form action={`${BASE}/${m.id}/note`} t={t} class="box">
544 <div class="body">
545 <div class="field">
546 <label for="ct-note">For the other site administrators: who replied, what was agreed. The sender never sees it.</label>
547 <textarea id="ct-note" name="note" maxlength={MAX_NOTE} style="min-height:80px;width:100%">
548 {m.note ?? ""}
549 </textarea>
550 </div>
551 <button class="btn">Save note</button>
552 </div>
553 </Form>
554 <h2>Delete</h2>
555 <Form action={`${BASE}/${m.id}/delete`} t={t} class="box" confirm={`Delete the message from ${m.email} and everything stored about it? This can't be undone.`}>
556 <div class="body row between" style="gap:12px;flex-wrap:wrap">
557 <span class="muted">For someone who asks for their data to be removed. The audit log keeps that a message was deleted, not what it said.</span>
558 <button class="btn danger">{raw(ICONS.trash)} Delete message</button>
559 </div>
560 </Form>
561 </>,
562 );
563 });
564
565 const audit = (c: AppContext) => (e: Parameters<Directory["audit"]>[0]) => c.get("dir").audit(e);
566 const DONE: Record<MessageState, string> = { new: "Reopened the message.", unconfirmed: "Reopened the message. Its address still has to confirm it.", handled: "Marked the message handled.", spam: "Marked the message as spam. Nothing more from its address gets through while it stays spam." };
567
568 post(web, `${BASE}/:id/state`, async (c, f) => {
569 const me = requireSiteAdmin(c);
570 return act(c, backTo(f.s("back")), async () => {
571 const m = await changeMessage(c.env.DB, audit(c), me, prm(c, "id"), { state: f.s("state") }, "web");
572 return void flash(c, "notice", DONE[m.state]);
573 });
574 });
575 post(
576 web,
577 `${BASE}/:id/note`,
578 async (c, f) => {
579 const me = requireSiteAdmin(c);
580 return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => {
581 const m = await changeMessage(c.env.DB, audit(c), me, prm(c, "id"), { note: f.s("note") }, "web");
582 return void flash(c, "notice", m.note ? "Note saved." : "Note cleared.");
583 });
584 },
585 { maxBytes: 16 * 1024 },
586 );
587 post(web, `${BASE}/:id/delete`, async (c) => {
588 const me = requireSiteAdmin(c);
589 return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => {
590 await deleteMessage(c.env.DB, audit(c), me, prm(c, "id"), "web");
591 flash(c, "notice", "Message deleted.");
592 return BASE;
593 });
594 });
595}