| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | import { requireAccount, type AppContext } from "../auth/principal.js"; |
| 5 | import { turnstileMode } from "../auth/signup.js"; |
| 6 | import { formStamp } from "../lib/early-access.js"; |
| 7 | import type { Account, Directory } from "../lib/directory.js"; |
| 8 | import { |
| 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"; |
| 27 | import { HttpError } from "../lib/util.js"; |
| 28 | import { ICONS } from "./assets.js"; |
| 29 | import { SettingsNav } from "./account.js"; |
| 30 | import { csrfToken } from "./csrf.js"; |
| 31 | import { CONFIRM_SCRIPT, Protections, guard } from "./early-access.js"; |
| 32 | import { ComputePausedBanner } from "./kill-switches.js"; |
| 33 | import { KSelect } from "./kumo.js"; |
| 34 | import { 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. */ |
| 43 | const MAX_FORM_BYTES = 64 * 1024; |
| 44 | const BASE = "/settings/admin/contact"; |
| 45 | |
| 46 | const 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 | |
| 48 | export const STATE_TEXT: Record<MessageState, string> = { new: "New", handled: "Handled", spam: "Spam", unconfirmed: "Unconfirmed" }; |
| 49 | const STATE_BADGE: Record<MessageState, string> = { new: "badge blue", handled: "badge green", spam: "badge red", unconfirmed: "badge" }; |
| 50 | const 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. */ |
| 53 | const senderOf = (c: AppContext): Account | null => { |
| 54 | const a = principalAccount(c); |
| 55 | return a && a.kind === "user" && a.status === "active" ? a : null; |
| 56 | }; |
| 57 | |
| 58 | interface FormValues { |
| 59 | email?: string; |
| 60 | name?: string; |
| 61 | topic?: string; |
| 62 | message?: string; |
| 63 | } |
| 64 | |
| 65 | // ---------------------------------------------------------------- the form |
| 66 | |
| 67 | const 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 | |
| 77 | async 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"> |
| 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 ?? ""} options={[{ value: "", label: "Choose a topic" }, ...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. */ |
| 171 | function 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. */ |
| 195 | function 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. */ |
| 215 | const SCRIPT = CONFIRM_SCRIPT.replace('"ea-confirm"', '"ct-confirm"').replace('"ea-confirm-status"', '"ct-confirm-status"'); |
| 216 | |
| 217 | function 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 | |
| 239 | function 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. */ |
| 279 | const initial = (m: Pick<ContactMessage, "name" | "email">) => (m.name || m.email).trim().charAt(0).toUpperCase() || "?"; |
| 280 | |
| 281 | const 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 | |
| 297 | type 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). */ |
| 300 | async 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. */ |
| 314 | const mailto = (m: ContactMessage) => `mailto:${encodeURIComponent(m.email).replace(/%40/g, "@")}?subject=${encodeURIComponent(`Re: ${topicLabel(m.topic)}`)}`; |
| 315 | |
| 316 | const backTo = (v: string): string => (v.startsWith(BASE) && !v.startsWith("//") && !/[\r\n]/.test(v) ? v : BASE); |
| 317 | |
| 318 | export 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}</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} <<a href={mailto(m)}>{m.email}</a>> |
| 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 | } |