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.

Contact page: form, confirmation, admin review and API (WIP)

Claude authored signed1 parent 2bf0f45commit 7c9e755f2a6887eb3ffb6532387aa1972ff5b581Browse files
21 files changed+1236−25
Files (21)
addedapps/diffinery/migrations/0043_contact.sql+41−0View file
@@ -1,0 +1,41 @@
1+-- Contact messages (lib/contact.ts): what people send through /contact to the instance's site
2+-- administrators. A message from someone who isn't signed in is unconfirmed until its address follows
3+-- the emailed link; a signed-in person's arrives confirmed. Confirmed messages are new until a site
4+-- administrator marks them handled or spam. Only site administrators can read them; the audit log keeps
5+-- every change. Unconfirmed messages are deleted after 7 days, spam after 30, handled after 365.
6+CREATE TABLE IF NOT EXISTS contact_messages (
7+ id TEXT PRIMARY KEY,
8+ email TEXT NOT NULL COLLATE NOCASE, -- where to reply: the address typed, or a signed-in sender's own
9+ name TEXT NOT NULL,
10+ topic TEXT NOT NULL, -- one of a fixed list
11+ message TEXT NOT NULL,
12+ account_id TEXT, -- the sender, when signed in
13+ sender_login TEXT,
14+ state TEXT NOT NULL DEFAULT 'unconfirmed' CHECK (state IN ('unconfirmed', 'new', 'handled', 'spam')),
15+ confirm_hash TEXT, -- SHA-256 of the emailed confirmation token, until it is used
16+ confirm_expires_at TEXT,
17+ confirmed_at TEXT,
18+ note TEXT, -- the site administrators' latest note
19+ updated_by TEXT, -- the site administrator's login who last changed it
20+ handled_at TEXT, -- when it was marked handled or spam
21+ created_at TEXT NOT NULL,
22+ updated_at TEXT NOT NULL
23+);
24+CREATE INDEX IF NOT EXISTS contact_messages_state ON contact_messages(state, created_at);
25+CREATE INDEX IF NOT EXISTS contact_messages_email ON contact_messages(email, state);
26+CREATE INDEX IF NOT EXISTS contact_messages_account ON contact_messages(account_id, created_at);
27+CREATE UNIQUE INDEX IF NOT EXISTS contact_messages_confirm ON contact_messages(confirm_hash);
28+
29+-- One row per form submission from someone who isn't signed in, for the daily limits only: hashes of
30+-- the sender's IP address and of the address typed, never either in the clear. Rows older than a day
31+-- are deleted.
32+CREATE TABLE IF NOT EXISTS contact_attempts (
33+ id TEXT PRIMARY KEY,
34+ ip_hash TEXT NOT NULL,
35+ email_hash TEXT NOT NULL,
36+ emailed INTEGER NOT NULL DEFAULT 0, -- 1 when this submission sent an email
37+ created_at TEXT NOT NULL
38+);
39+CREATE INDEX IF NOT EXISTS contact_attempts_ip ON contact_attempts(ip_hash, created_at);
40+CREATE INDEX IF NOT EXISTS contact_attempts_email ON contact_attempts(email_hash, created_at);
41+CREATE INDEX IF NOT EXISTS contact_attempts_at ON contact_attempts(created_at);
modifiedapps/diffinery/src/app.ts+2−0View file
@@ -38,6 +38,7 @@
3838 import { securityTxtRoutes } from "./http/security-txt.js";
3939 import { mcpRegistryAuthRoutes } from "./http/mcp-registry-auth.js";
4040 import { securityReportsApi } from "./http/security-reports.js";
41+import { contactApi } from "./http/contact.js";
4142 import { earlyAccessApi } from "./http/early-access.js";
4243 import { statusApi } from "./http/status.js";
4344 import { onReviewEvent } from "./agentic/reviews.js";
@@ -130,6 +131,7 @@
130131 app.route("/", mailApi);
131132 app.route("/", securityReportsApi);
132133 app.route("/", earlyAccessApi);
134+app.route("/", contactApi);
133135 app.route("/", statusApi);
134136 app.route("/", mailEvents);
135137 app.route("/", registry);
modifiedapps/diffinery/src/docs/api-meta.ts+25−0View file
@@ -121,6 +121,10 @@
121121 "POST /diffinery/admin/early-access/{id}/invite": { scope: "admin" },
122122 "PATCH /diffinery/admin/early-access/{id}": { scope: "admin" },
123123 "DELETE /diffinery/admin/early-access/{id}": { scope: "admin" },
124+ "GET /diffinery/admin/contact": { scope: "read" },
125+ "GET /diffinery/admin/contact/{id}": { scope: "read" },
126+ "PATCH /diffinery/admin/contact/{id}": { scope: "admin" },
127+ "DELETE /diffinery/admin/contact/{id}": { scope: "admin" },
124128 "GET /diffinery/admin/status/incidents": { scope: "read" },
125129 "POST /diffinery/admin/status/incidents": { scope: "admin" },
126130 "POST /diffinery/admin/status/incidents/{id}/updates": { scope: "admin" },
@@ -1629,6 +1633,27 @@
16291633 access: "site-admin",
16301634 status: 204,
16311635 },
1636+ "GET /diffinery/admin/contact": {
1637+ summary: "List contact messages",
1638+ description:
1639+ "Messages sent through `/contact`, newest first: `id`, `state` (`unconfirmed` until the address follows the emailed link, then `new`, `handled` or `spam`), `email`, `name`, `topic` (`question`, `account`, `self-hosting`, `feedback`, `partnership`, `press`, `privacy` or `other`), `message`, `sender` (the login of a signed-in sender, or null), `confirmed_at`, `note`, `updated_by`, `handled_at`, `created_at`, `updated_at` and `html_url`. 404 for anyone but a site administrator. See [Contact form](/docs/admin/contact-form).",
1640+ access: "site-admin",
1641+ query: { state: ["string", "Only messages in this state: `new`, `handled`, `spam` or `unconfirmed`."], q: ["string", "Only messages whose address, name or text contains this."], per_page: ["integer", "At most this many (default 100, up to 500)."], page: ["integer", "Page number, from 1."] },
1642+ },
1643+ "GET /diffinery/admin/contact/{id}": { summary: "Get a contact message", description: "One message, as in the list. 404 when there is none, or for anyone but a site administrator.", access: "site-admin" },
1644+ "PATCH /diffinery/admin/contact/{id}": {
1645+ summary: "Mark a contact message handled or spam, or note it",
1646+ description:
1647+ "`handled` or `spam` marks it; `new` reopens it (as `unconfirmed` if its address never confirmed it). `note` sets the note for the other site administrators (an empty one clears it; leaving it out keeps it). Nobody is emailed. Recorded in the audit log as `contact.handle`, `contact.spam`, `contact.reopen` or `contact.note`. While a message from an address is `spam`, new messages from that address are dropped without an email. 404 for anyone but a site administrator.",
1648+ access: "site-admin",
1649+ body: { state: ["string", "`handled`, `spam`, or `new` to reopen it."], note: ["string", "For the other site administrators, up to 2,000 characters."] },
1650+ },
1651+ "DELETE /diffinery/admin/contact/{id}": {
1652+ summary: "Delete a contact message",
1653+ description: "Removes the message, for someone who asks for their data to be removed. Recorded in the audit log as `contact.delete`, without the message or its address. 404 for anyone but a site administrator.",
1654+ access: "site-admin",
1655+ status: 204,
1656+ },
16321657 "GET /diffinery/status": {
16331658 summary: "Get the instance's status",
16341659 description:
addedapps/diffinery/src/http/contact.ts+59−0View file
@@ -1,0 +1,59 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js";
4+import type { Account } from "../lib/directory.js";
5+import { ContactMessages, MESSAGE_STATES, changeMessage, deleteMessage, messageJson, type MessageState } from "../lib/contact.js";
6+import { HttpError } from "../lib/util.js";
7+
8+/**
9+ * Contact messages over the REST API (lib/contact.ts), for site administrators only.
10+ * To anyone else these endpoints don't exist (404), like the messages themselves.
11+ */
12+export const contactApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
13+
14+const siteAdmin = (c: AppContext, scope: "read" | "admin"): Account => {
15+ const a = requireAccount(c);
16+ if (!a.site_admin || a.kind !== "user") throw new HttpError(404, "not found");
17+ requireAccountScope(c, scope);
18+ return a;
19+};
20+
21+const audit = (c: AppContext) => (e: Parameters<AppContext["var"]["dir"]["audit"]>[0]) => c.get("dir").audit(e);
22+
23+contactApi.get("/diffinery/admin/contact", async (c) => {
24+ siteAdmin(c, "read");
25+ const s = c.req.query("state");
26+ if (s && !MESSAGE_STATES.includes(s as MessageState)) throw new HttpError(422, `state must be one of ${MESSAGE_STATES.join(", ")}`);
27+ const n = Number(c.req.query("per_page") ?? 100);
28+ const page = Math.max(1, Math.min(1000, Number(c.req.query("page") ?? 1) || 1));
29+ const limit = Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), 500) : 100;
30+ const { items } = await new ContactMessages(c.env.DB).list({ state: (s as MessageState) || undefined, q: c.req.query("q") ?? "", limit, offset: (page - 1) * limit });
31+ return c.json(items.map((m) => messageJson(c.env, m)));
32+});
33+
34+contactApi.get("/diffinery/admin/contact/:id", async (c) => {
35+ siteAdmin(c, "read");
36+ const m = await new ContactMessages(c.env.DB).get(c.req.param("id"));
37+ if (!m) throw new HttpError(404, "no contact message with that id");
38+ return c.json(messageJson(c.env, m));
39+});
40+
41+/** Mark a message handled or spam, reopen it (`{"state": "new"}`), or set its note. Audited; nobody is emailed. */
42+contactApi.patch("/diffinery/admin/contact/:id", async (c) => {
43+ const me = siteAdmin(c, "admin");
44+ let b: { state?: unknown; note?: unknown };
45+ try {
46+ b = (await c.req.json()) as typeof b;
47+ } catch {
48+ throw new HttpError(400, "request body must be JSON");
49+ }
50+ if (!b || typeof b !== "object") throw new HttpError(422, 'send {"state": "handled"}, "spam" or "new", a "note", or both');
51+ return c.json(messageJson(c.env, await changeMessage(c.env.DB, audit(c), me, c.req.param("id"), { state: b.state, note: b.note }, "api")));
52+});
53+
54+/** Delete a message (data removal). Audited without its content. */
55+contactApi.delete("/diffinery/admin/contact/:id", async (c) => {
56+ const me = siteAdmin(c, "admin");
57+ await deleteMessage(c.env.DB, audit(c), me, c.req.param("id"), "api");
58+ return c.body(null, 204);
59+});
addedapps/diffinery/src/lib/contact.ts+372−0View file
@@ -1,0 +1,372 @@
1+import type { Env } from "../env.js";
2+import type { Account } from "./directory.js";
3+import { normalEmail } from "../auth/signup.js";
4+import { enqueueMail } from "../mail/send.js";
5+import type { MailJob } from "../mail/types.js";
6+import { HttpError, newId, now, randomToken, sha256 } from "./util.js";
7+
8+/**
9+ * Contact messages: anyone can write to an instance's site administrators at /contact
10+ * (web/contact.tsx), signed in or not. Someone who isn't signed in passes Cloudflare Turnstile, a
11+ * hidden honeypot field and a signed timing stamp (the early access form's protections), and their
12+ * message stays unconfirmed until its address follows the emailed link: so nobody can put words in
13+ * someone else's name, and replies only ever go to an inbox that asked for them. A signed-in person's
14+ * message arrives confirmed, from their account's address.
15+ *
16+ * A confirmed message is new until a site administrator marks it handled or spam, on Site
17+ * administration → Contact messages. Each one emails the site administrators (from the sender's
18+ * address as Reply-To, so they can answer from their mailbox) and raises one in-app notification.
19+ *
20+ * The confirmation email never repeats anything typed into the form, so the form can't be used to
21+ * send someone else a message through Diffinery. An address with a message marked spam gets nothing
22+ * through until that message is put back or deleted.
23+ */
24+
25+export type MessageState = "unconfirmed" | "new" | "handled" | "spam";
26+export const MESSAGE_STATES: readonly MessageState[] = ["new", "handled", "spam", "unconfirmed"];
27+
28+/** Longest name, message and note, in characters, and the shortest message. Longer is refused, never cut. */
29+export const MAX_NAME = 100;
30+export const MAX_MESSAGE = 5_000;
31+export const MIN_MESSAGE = 10;
32+export const MAX_NOTE = 2_000;
33+/** Links in one message: spam carries many, people rarely need more. */
34+export const MAX_LINKS = 5;
35+
36+/** What a message is about: the only values the topic takes. */
37+export const TOPICS = [
38+ { value: "question", label: "A question about Diffinery" },
39+ { value: "account", label: "My account or invitation" },
40+ { value: "self-hosting", label: "Running my own Diffinery" },
41+ { value: "feedback", label: "Feedback or an idea" },
42+ { value: "partnership", label: "Partnerships and integrations" },
43+ { value: "press", label: "Press and media" },
44+ { value: "privacy", label: "Privacy and my data" },
45+ { value: "other", label: "Something else" },
46+] as const;
47+export type Topic = (typeof TOPICS)[number]["value"];
48+export const topicLabel = (v: string): string => TOPICS.find((t) => t.value === v)?.label ?? v;
49+export const isTopic = (v: string): v is Topic => TOPICS.some((t) => t.value === v);
50+
51+/** How long a confirmation link works, and how long unconfirmed, spam and handled messages are kept. */
52+export const CONFIRM_HOURS = 48;
53+export const UNCONFIRMED_DAYS = 7;
54+export const SPAM_DAYS = 30;
55+export const HANDLED_DAYS = 365;
56+
57+/** In 24 hours: submissions from one IP address, emails to one address, and emails from the form in total (people not signed in). */
58+export const SUBMISSIONS_PER_IP_PER_DAY = 10;
59+export const EMAILS_PER_ADDRESS_PER_DAY = 3;
60+export const EMAILS_PER_DAY = 300;
61+/** In 24 hours: messages from one signed-in person. */
62+export const MESSAGES_PER_ACCOUNT_PER_DAY = 20;
63+
64+export interface ContactMessage {
65+ id: string;
66+ email: string;
67+ name: string;
68+ topic: string;
69+ message: string;
70+ account_id: string | null;
71+ sender_login: string | null;
72+ state: MessageState;
73+ confirm_hash: string | null;
74+ confirm_expires_at: string | null;
75+ confirmed_at: string | null;
76+ note: string | null;
77+ updated_by: string | null;
78+ handled_at: string | null;
79+ created_at: string;
80+ updated_at: string;
81+}
82+
83+export interface MessageInput {
84+ /** Null for a signed-in sender, whose account's address is used. */
85+ email: string | null;
86+ name: string;
87+ topic: Topic;
88+ message: string;
89+}
90+
91+/** Control characters (except tab and newline) and bidirectional overrides, which could disguise text on the admin page. */
92+// eslint-disable-next-line no-control-regex
93+const INVISIBLE = /[\u0000-\u0008\u000B-\u001F\u007F‎‏‪-‮⁦-⁩]/g;
94+
95+const LINK = /\bhttps?:\/\/|\bwww\./gi;
96+
97+/**
98+ * Check what someone typed: their name, an email address (only when not signed in), a topic from the
99+ * list, and the message. One-line fields lose extra spaces; the message keeps its lines. Too long is
100+ * refused with the limit, never cut.
101+ */
102+export function validateMessage(raw: { email?: unknown; name?: unknown; topic?: unknown; message?: unknown }, signedIn: boolean): MessageInput {
103+ const str = (v: unknown) => (typeof v === "string" ? v.replace(INVISIBLE, "") : "");
104+ const line = (v: unknown) => str(v).replace(/\s+/g, " ").trim();
105+ const name = line(raw.name);
106+ const topic = line(raw.topic);
107+ const message = str(raw.message)
108+ .replace(/\r\n?/g, "\n")
109+ .replace(/\t/g, " ")
110+ .replace(/\n{3,}/g, "\n\n")
111+ .trim();
112+ let email: string | null = null;
113+ if (!signedIn) {
114+ const typed = line(raw.email);
115+ if (!typed) throw new HttpError(422, "Enter your email address, so we can reply.");
116+ email = normalEmail(typed);
117+ if (!email) throw new HttpError(422, "Enter a valid email address, like you@example.com.");
118+ }
119+ if (!name) throw new HttpError(422, "Enter your name.");
120+ if (name.length > MAX_NAME) throw new HttpError(422, `Keep your name to ${MAX_NAME} characters.`);
121+ if (!topic) throw new HttpError(422, "Choose what your message is about.");
122+ if (!isTopic(topic)) throw new HttpError(422, "Choose what your message is about from the list.");
123+ if (!message) throw new HttpError(422, "Write your message.");
124+ if (message.length < MIN_MESSAGE) throw new HttpError(422, `Write a little more: at least ${MIN_MESSAGE} characters.`);
125+ if (message.length > MAX_MESSAGE) throw new HttpError(422, `Keep your message to ${MAX_MESSAGE.toLocaleString("en")} characters. Offer to send more once we reply.`);
126+ if ((message.match(LINK) ?? []).length > MAX_LINKS) throw new HttpError(422, `Keep your message to ${MAX_LINKS} links or fewer.`);
127+ return { email, name, topic, message };
128+}
129+
130+// ---------------------------------------------------------------- storage
131+
132+/** Only hashes of the sender's IP address and of the typed address are kept for the daily limits. */
133+export const ipHash = (ip: string | null | undefined): Promise<string> => sha256(`diffinery-contact-ip:${ip || "unknown"}`);
134+export const emailHash = (email: string): Promise<string> => sha256(`diffinery-contact-email:${email.toLowerCase()}`);
135+
136+const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
137+
138+/** Contact messages in D1. */
139+export class ContactMessages {
140+ constructor(private db: D1Database) {}
141+
142+ async get(id: string): Promise<ContactMessage | null> {
143+ if (!/^[0-9a-f]{32}$/.test(id)) return null;
144+ return this.db.prepare("SELECT * FROM contact_messages WHERE id = ?").bind(id).first<ContactMessage>();
145+ }
146+
147+ /**
148+ * Newest first, optionally in one state and matching `q` (in the address, name or message).
149+ * One more than `limit` is read, so callers know whether there is a next page.
150+ */
151+ async list(o: { state?: MessageState; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: ContactMessage[]; more: boolean }> {
152+ const limit = Math.min(Math.max(o.limit ?? 50, 1), 500);
153+ const offset = Math.min(Math.max(o.offset ?? 0, 0), 100_000);
154+ const where: string[] = [];
155+ const binds: unknown[] = [];
156+ if (o.state) {
157+ where.push("state = ?");
158+ binds.push(o.state);
159+ }
160+ const q = (o.q ?? "").trim().slice(0, 100);
161+ if (q) {
162+ const like = `%${likeEscape(q)}%`;
163+ where.push("(email LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\')");
164+ binds.push(like, like, like);
165+ }
166+ const rows = (
167+ await this.db
168+ .prepare(`SELECT * FROM contact_messages${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`)
169+ .bind(...binds, limit + 1, offset)
170+ .all<ContactMessage>()
171+ ).results;
172+ return { items: rows.slice(0, limit), more: rows.length > limit };
173+ }
174+
175+ async counts(): Promise<Record<MessageState, number>> {
176+ const rows = (await this.db.prepare("SELECT state, count(*) AS n FROM contact_messages GROUP BY state").all<{ state: MessageState; n: number }>()).results;
177+ const out: Record<MessageState, number> = { unconfirmed: 0, new: 0, handled: 0, spam: 0 };
178+ for (const r of rows) out[r.state] = r.n;
179+ return out;
180+ }
181+
182+ /** New messages waiting for a site administrator: the count on the Site administration menu. */
183+ async waiting(): Promise<number> {
184+ return (await this.db.prepare("SELECT count(*) AS n FROM contact_messages WHERE state = 'new'").first<{ n: number }>())?.n ?? 0;
185+ }
186+
187+ /** Whether a site administrator marked a message from this address as spam (and it's still kept). */
188+ async spammer(email: string): Promise<boolean> {
189+ return !!(await this.db.prepare("SELECT 1 AS x FROM contact_messages WHERE email = ? AND state = 'spam' LIMIT 1").bind(email).first());
190+ }
191+
192+ /** Submissions from this IP address, emails to this address, and emails in total, in the last 24 hours. */
193+ async sentToday(ip: string, email: string): Promise<{ ip: number; email: number; all: number }> {
194+ const since = new Date(Date.now() - 86400_000).toISOString();
195+ const r = await this.db
196+ .prepare(
197+ "SELECT (SELECT count(*) FROM contact_attempts WHERE ip_hash = ? AND created_at > ?) AS ip, (SELECT count(*) FROM contact_attempts WHERE email_hash = ? AND emailed = 1 AND created_at > ?) AS email, (SELECT count(*) FROM contact_attempts WHERE emailed = 1 AND created_at > ?) AS a",
198+ )
199+ .bind(ip, since, email, since, since)
200+ .first<{ ip: number; email: number; a: number }>();
201+ return { ip: r?.ip ?? 0, email: r?.email ?? 0, all: r?.a ?? 0 };
202+ }
203+
204+ /** Messages from this signed-in person in the last 24 hours. */
205+ async fromAccountToday(accountId: string): Promise<number> {
206+ const since = new Date(Date.now() - 86400_000).toISOString();
207+ return (await this.db.prepare("SELECT count(*) AS n FROM contact_messages WHERE account_id = ? AND created_at > ?").bind(accountId, since).first<{ n: number }>())?.n ?? 0;
208+ }
209+
210+ async attempt(ip: string, email: string, emailed: boolean): Promise<void> {
211+ await this.db.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), ip, email, emailed ? 1 : 0, now()).run();
212+ }
213+}
214+
215+// ---------------------------------------------------------------- sending and confirming
216+
217+type Audit = (e: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
218+
219+/** What a submission did, for tests and logs; the visitor sees the same page whatever it is. */
220+export type Outcome = "confirm" | "sent" | "blocked" | "limited";
221+
222+/**
223+ * Take a validated message. `sender` is the signed-in person (their message arrives new, they get a
224+ * receipt and the site administrators hear about it now), or null: then the message is stored
225+ * unconfirmed and the typed address gets a link to confirm it.
226+ *
227+ * Refuses (429) when this network or this person sent too many today. Otherwise the visitor sees the
228+ * same page whatever happens; no email is sent and nothing is stored once the address had
229+ * EMAILS_PER_ADDRESS_PER_DAY today or the form EMAILS_PER_DAY in total, or when a message from the
230+ * address is marked spam.
231+ */
232+export async function submitMessage(env: Env, audit: Audit, input: MessageInput, sender: Account | null, ip: string | null): Promise<Outcome> {
233+ const store = new ContactMessages(env.DB);
234+ const at = now();
235+ if (sender) {
236+ if ((await store.fromAccountToday(sender.id)) >= MESSAGES_PER_ACCOUNT_PER_DAY) throw new HttpError(429, `You have sent ${MESSAGES_PER_ACCOUNT_PER_DAY} messages today. We'll reply to those first; try again tomorrow.`);
237+ const email = sender.email ?? "";
238+ if (!email) throw new HttpError(422, "Your account has no email address to reply to. Add one in Settings → Email first.");
239+ if (await store.spammer(email)) return "blocked";
240+ const id = newId();
241+ await env.DB.prepare("INSERT INTO contact_messages (id, email, name, topic, message, account_id, sender_login, state, confirmed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)")
242+ .bind(id, email, input.name, input.topic, input.message, sender.id, sender.login, at, at, at)
243+ .run();
244+ await audit({ actorId: sender.id, actorLogin: sender.login, action: "contact.create", target: id, detail: { signed_in: true } });
245+ await enqueueMail(env, [{ kind: "contact_received", data: { id, topic: topicLabel(input.topic), message: input.message }, to: { accountId: sender.id }, reason: "account", at }]);
246+ await notifyAdmins(env, { id, email, name: input.name, topic: input.topic, message: input.message, sender_login: sender.login, created_at: at });
247+ return "sent";
248+ }
249+
250+ const email = input.email!;
251+ const [ipH, emailH] = await Promise.all([ipHash(ip), emailHash(email)]);
252+ const sent = await store.sentToday(ipH, emailH);
253+ if (sent.ip >= SUBMISSIONS_PER_IP_PER_DAY) throw new HttpError(429, "Too many messages from your network today. Please try again tomorrow.");
254+ if (sent.email >= EMAILS_PER_ADDRESS_PER_DAY || sent.all >= EMAILS_PER_DAY) {
255+ await store.attempt(ipH, emailH, false);
256+ return "limited";
257+ }
258+ if (await store.spammer(email)) {
259+ await store.attempt(ipH, emailH, false);
260+ return "blocked";
261+ }
262+ const id = newId();
263+ const token = randomToken(24);
264+ const expiresAt = new Date(Date.now() + CONFIRM_HOURS * 3600_000).toISOString();
265+ await env.DB.prepare("INSERT INTO contact_messages (id, email, name, topic, message, state, confirm_hash, confirm_expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'unconfirmed', ?, ?, ?, ?)")
266+ .bind(id, email, input.name, input.topic, input.message, await sha256(token), expiresAt, at, at)
267+ .run();
268+ await store.attempt(ipH, emailH, true);
269+ await audit({ action: "contact.create", target: id, detail: { signed_in: false } });
270+ // The token rides in the fragment, which browsers never send, so request logs never hold it.
271+ await enqueueMail(env, [{ kind: "contact_confirm", data: { url: `${env.PUBLIC_URL}/contact/confirm#${token}`, expiresAt, topic: topicLabel(input.topic) }, to: { email }, reason: "contact", at }]);
272+ return "confirm";
273+}
274+
275+/**
276+ * Confirm the message a link's token belongs to: it becomes new, and the site administrators hear
277+ * about it. Null when the token is unknown, already used or expired.
278+ */
279+export async function confirmMessage(env: Env, audit: Audit, token: string): Promise<ContactMessage | null> {
280+ if (!/^[A-Za-z0-9_-]{20,100}$/.test(token)) return null;
281+ const hash = await sha256(token);
282+ const at = now();
283+ const row = await env.DB.prepare("SELECT * FROM contact_messages WHERE confirm_hash = ? AND state = 'unconfirmed' AND confirm_expires_at > ?").bind(hash, at).first<ContactMessage>();
284+ if (!row) return null;
285+ const done = await env.DB.prepare("UPDATE contact_messages SET state = 'new', confirmed_at = ?, confirm_hash = NULL, confirm_expires_at = NULL, updated_at = ? WHERE id = ? AND confirm_hash = ? AND state = 'unconfirmed'").bind(at, at, row.id, hash).run();
286+ if (!done.meta.changes) return null;
287+ await audit({ action: "contact.confirm", target: row.id });
288+ await notifyAdmins(env, row);
289+ return { ...row, state: "new", confirmed_at: at, confirm_hash: null, confirm_expires_at: null, updated_at: at };
290+}
291+
292+/**
293+ * Tell every active site administrator about a new message: an email each (the "Organizations" group
294+ * of their notification settings, with the sender as Reply-To), and one in-app notification, which
295+ * stays one however many messages arrive until it is read.
296+ */
297+export async function notifyAdmins(env: Env, m: Pick<ContactMessage, "id" | "email" | "name" | "topic" | "message" | "sender_login" | "created_at">): Promise<void> {
298+ const db = env.DB;
299+ const admins = (await db.prepare("SELECT id FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND suspended_at IS NULL LIMIT 50").all<{ id: string }>()).results;
300+ if (!admins.length) return;
301+ const at = now();
302+ await db.batch(
303+ admins.map((a) =>
304+ db
305+ .prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) SELECT ?, ?, NULL, 'contact', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM notifications WHERE account_id = ? AND kind = 'contact' AND read_at IS NULL)")
306+ .bind(newId(), a.id, "New contact messages are waiting for you", "/settings/admin/contact", at, a.id),
307+ ),
308+ );
309+ await enqueueMail(
310+ env,
311+ admins.map((a): MailJob => ({ kind: "contact_message", data: { id: m.id, name: m.name, email: m.email, login: m.sender_login, topic: topicLabel(m.topic), message: m.message }, to: { accountId: a.id }, reason: "admin", at: m.created_at })),
312+ );
313+}
314+
315+// ---------------------------------------------------------------- site administrators
316+
317+/** A message that exists, or 404. */
318+async function existing(db: D1Database, id: string): Promise<ContactMessage> {
319+ const m = await new ContactMessages(db).get(id);
320+ if (!m) throw new HttpError(404, "no contact message with that id");
321+ return m;
322+}
323+
324+const ACTION: Record<"new" | "handled" | "spam", string> = { new: "contact.reopen", handled: "contact.handle", spam: "contact.spam" };
325+
326+/**
327+ * Mark a message handled or spam, or put it back (`new`: new again, or unconfirmed if its address
328+ * never confirmed it), and keep a note for the other site administrators when one is given (an empty
329+ * note clears it; leaving it out keeps it). Nobody is emailed. Audited, without the message.
330+ */
331+export async function changeMessage(db: D1Database, audit: Audit, me: Account, id: string, change: { state?: unknown; note?: unknown }, via: "web" | "api"): Promise<ContactMessage> {
332+ const { state, note } = change;
333+ if (state !== undefined && state !== "new" && state !== "handled" && state !== "spam") throw new HttpError(422, "state must be one of new, handled, spam");
334+ if (note !== undefined && note !== null && typeof note !== "string") throw new HttpError(422, "note must be a string");
335+ if (state === undefined && note === undefined) throw new HttpError(422, "send a state, a note, or both");
336+ const text = typeof note === "string" ? note.replace(/\r\n?/g, "\n").trim() : "";
337+ if (text.length > MAX_NOTE) throw new HttpError(422, `Keep the note to ${MAX_NOTE.toLocaleString("en")} characters.`);
338+ const m = await existing(db, id);
339+ const at = now();
340+ const next: MessageState = state === undefined ? m.state : state === "new" ? (m.confirmed_at ? "new" : "unconfirmed") : state;
341+ const newNote = note === undefined ? m.note : text || null;
342+ const handledAt = next === "handled" || next === "spam" ? (next === m.state ? m.handled_at : at) : null;
343+ await db.prepare("UPDATE contact_messages SET state = ?, note = ?, updated_by = ?, handled_at = ?, updated_at = ? WHERE id = ?").bind(next, newNote, me.login, handledAt, at, m.id).run();
344+ if (next !== m.state) await audit({ actorId: me.id, actorLogin: me.login, action: ACTION[state as "new" | "handled" | "spam"], target: m.id, detail: { from: m.state, to: next, via } });
345+ if (newNote !== m.note) await audit({ actorId: me.id, actorLogin: me.login, action: "contact.note", target: m.id, detail: { via } });
346+ return { ...m, state: next, note: newNote, updated_by: me.login, handled_at: handledAt, updated_at: at };
347+}
348+
349+/** Delete a message, for someone who asks for their data to be removed. The audit log records that it happened, never what it said or its address. */
350+export async function deleteMessage(db: D1Database, audit: Audit, me: Account, id: string, via: "web" | "api"): Promise<void> {
351+ const m = await existing(db, id);
352+ await db.prepare("DELETE FROM contact_messages WHERE id = ?").bind(m.id).run();
353+ await audit({ actorId: me.id, actorLogin: me.login, action: "contact.delete", target: m.id, detail: { state: m.state, via } });
354+}
355+
356+/** The API's view of a message. */
357+export const messageJson = (env: Pick<Env, "PUBLIC_URL">, m: ContactMessage) => ({
358+ id: m.id,
359+ state: m.state,
360+ email: m.email,
361+ name: m.name,
362+ topic: m.topic,
363+ message: m.message,
364+ sender: m.sender_login,
365+ confirmed_at: m.confirmed_at,
366+ note: m.note,
367+ updated_by: m.updated_by,
368+ handled_at: m.handled_at,
369+ created_at: m.created_at,
370+ updated_at: m.updated_at,
371+ html_url: `${env.PUBLIC_URL}/settings/admin/contact/${m.id}`,
372+});
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
@@ -10,7 +10,8 @@
1010 *
1111 * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, private
1212 * security reports (/security/report), early access requests and their confirmations
13- * (/early-access and its confirm and remove pages), CLI sign-ins (starting one, typing or approving a
13+ * (/early-access and its confirm and remove pages), contact messages and their confirmations
14+ * (/contact and /contact/confirm), CLI sign-ins (starting one, typing or approving a
1415 * code), and MCP apps registering themselves for OAuth (/oauth/register), per IP address.
1516 * - RL_ANON: everything else from someone who isn't signed in, per IP address.
1617 * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account; from an
@@ -35,7 +36,7 @@
3536
3637 /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
3738 const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
38-const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|-\/cli\/device$|oauth\/register$)/;
39+const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|contact(?:\/confirm)?$|-\/cli\/device$|oauth\/register$)/;
3940 const CREATE: Array<[method: string, path: RegExp]> = [
4041 ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
4142 ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
@@ -97,7 +98,7 @@
9798 }
9899
99100 const WHAT: Record<Bucket, string> = {
100- RL_AUTH: "sign-in attempts, security reports and early access requests from your network",
101+ RL_AUTH: "sign-in attempts, security reports, early access requests and contact messages from your network",
101102 RL_ANON: "requests from your network without signing in",
102103 RL_ACCOUNT: "requests from your account",
103104 RL_CREATE: "new repositories, issues, pull requests and comments from your account",
modifiedapps/diffinery/src/lib/retention.ts+13−3View file
@@ -1,3 +1,4 @@
1+import { HANDLED_DAYS, SPAM_DAYS, UNCONFIRMED_DAYS as CONTACT_UNCONFIRMED_DAYS } from "./contact.js";
12 import { UNCONFIRMED_DAYS } from "./early-access.js";
23
34 /**
@@ -10,21 +11,28 @@
1011 * - hosted compute records (lib/quotas.ts) ended more than COMPUTE_RETENTION_DAYS ago, past any month
1112 * the usage pages show;
1213 * - early access requests (lib/early-access.ts) never confirmed within UNCONFIRMED_DAYS of their last
13- * submission, and the form's daily-limit rows once they are a day old.
14+ * submission, and the form's daily-limit rows once they are a day old;
15+ * - contact messages (lib/contact.ts) never confirmed within CONTACT_UNCONFIRMED_DAYS, marked spam more
16+ * than SPAM_DAYS ago or handled more than HANDLED_DAYS ago, and the form's daily-limit rows once they
17+ * are a day old.
1418 */
1519 export const NOTIFICATION_RETENTION_DAYS = 180;
1620 export const COMPUTE_RETENTION_DAYS = 400;
1721 /** Rows deleted per table per run: an hourly run keeps up with 120,000 new rows a day per table. */
1822 export const RETENTION_BATCH = 5000;
1923
20-export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage" | "early_access" | "early_access_attempts", number>> {
24+export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage" | "early_access" | "early_access_attempts" | "contact_messages" | "contact_attempts", number>> {
2125 const ago = (days: number) => new Date(at.getTime() - days * 86400_000).toISOString();
22- const [sessions, notifications, compute, early, attempts] = await db.batch([
26+ const [sessions, notifications, compute, early, attempts, contact, contactAttempts] = await db.batch([
2327 db.prepare("DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE expires_at < ? LIMIT ?)").bind(ago(1), batch),
2428 db.prepare("DELETE FROM notifications WHERE id IN (SELECT id FROM notifications WHERE created_at < ? LIMIT ?)").bind(ago(NOTIFICATION_RETENTION_DAYS), batch),
2529 db.prepare("DELETE FROM compute_usage WHERE id IN (SELECT id FROM compute_usage WHERE ended_at < ? LIMIT ?)").bind(ago(COMPUTE_RETENTION_DAYS), batch),
2630 db.prepare("DELETE FROM early_access WHERE id IN (SELECT id FROM early_access WHERE state = 'unconfirmed' AND updated_at < ? LIMIT ?)").bind(ago(UNCONFIRMED_DAYS), batch),
2731 db.prepare("DELETE FROM early_access_attempts WHERE id IN (SELECT id FROM early_access_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch),
32+ db
33+ .prepare("DELETE FROM contact_messages WHERE id IN (SELECT id FROM contact_messages WHERE (state = 'unconfirmed' AND created_at < ?) OR (state = 'spam' AND handled_at < ?) OR (state = 'handled' AND handled_at < ?) LIMIT ?)")
34+ .bind(ago(CONTACT_UNCONFIRMED_DAYS), ago(SPAM_DAYS), ago(HANDLED_DAYS), batch),
35+ db.prepare("DELETE FROM contact_attempts WHERE id IN (SELECT id FROM contact_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch),
2836 ]);
2937 return {
3038 sessions: sessions!.meta.changes ?? 0,
@@ -32,5 +40,7 @@
3240 compute_usage: compute!.meta.changes ?? 0,
3341 early_access: early!.meta.changes ?? 0,
3442 early_access_attempts: attempts!.meta.changes ?? 0,
43+ contact_messages: contact!.meta.changes ?? 0,
44+ contact_attempts: contactAttempts!.meta.changes ?? 0,
3545 };
3646 }
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -111,7 +111,7 @@
111111 }
112112
113113 /** Top-level paths the app serves; no account may use them as its login. */
114-export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
114+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "contact", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
115115
116116 /**
117117 * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
modifiedapps/diffinery/src/mail/prefs.ts+1−1View file
@@ -12,7 +12,7 @@
1212 actions: { web: boolean; email: boolean; failuresOnly: boolean };
1313 /** Repositories created, deleted, or access granted to you. */
1414 repos: { email: boolean };
15- /** Organization membership, accepted invitations and (for admins) access requests. */
15+ /** Organization membership, accepted invitations and (for admins) access requests and contact messages. */
1616 orgs: { email: boolean };
1717 /** Also email me about my own comments and changes (GitHub's "Include your own updates"). */
1818 ownActivity: boolean;
modifiedapps/diffinery/src/mail/samples.ts+7−0View file
@@ -94,6 +94,13 @@
9494 item("early-access-waiting", "Early access: already on the list", { kind: "early_access_known", reason: "early_access", data: { variant: "waiting", since: at } }),
9595 item("early-access-invited", "Early access: already invited", { kind: "early_access_known", reason: "early_access", data: { variant: "invited", since: at } }),
9696 item("early-access-account", "Early access: already has an account", { kind: "early_access_known", reason: "early_access", data: { variant: "account", since: at } }),
97+ item("contact-confirm", "Contact: confirm the message", { kind: "contact_confirm", reason: "contact", data: { url: `${pub}/contact/confirm#sample`, expiresAt: exp, topic: "A question about Diffinery" } }),
98+ item("contact-received", "Contact: copy for a signed-in sender", { kind: "contact_received", reason: "account", data: { id: "0123456789abcdef0123456789abcdef", topic: "Running my own Diffinery", message: "Hi! We'd like to run Diffinery for our 40 repositories.\n\nDoes it work with a custom domain on a Cloudflare zone we already have?" } }),
99+ item("contact-message", "Contact: new message for site administrators", {
100+ kind: "contact_message",
101+ reason: "admin",
102+ data: { id: "0123456789abcdef0123456789abcdef", name: "Rivera Kim", email: "rivera@example.com", login: null, topic: "Partnerships and integrations", message: "Hello,\n\nWe build a code review tool and would like to list Diffinery as a supported forge. Who should we talk to?\n\nThanks, Rivera" },
103+ }),
97104 item("security-report", "Security report", { kind: "security_report", reason: "admin", data: { id: "0123456789abcdef0123456789abcdef", summary: "Stored XSS in release notes" } }),
98105 item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }),
99106 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
modifiedapps/diffinery/src/mail/send.ts+2−0View file
@@ -91,6 +91,7 @@
9191 html: string;
9292 text: string;
9393 headers: Record<string, string>;
94+ replyTo?: { name: string; email: string } | string;
9495 }
9596
9697 /** Render a job for one recipient: the message plus the headers mail clients use for threading and unsubscribing. */
@@ -143,6 +144,7 @@
143144 html: toHtml(email, ctx),
144145 text: toText(email, ctx),
145146 headers,
147+ ...(email.replyTo ? { replyTo: email.replyTo.name ? { name: clean(email.replyTo.name), email: email.replyTo.email } : email.replyTo.email } : {}),
146148 };
147149 }
148150
modifiedapps/diffinery/src/mail/templates.ts+57−1View file
@@ -17,7 +17,9 @@
1717 | { callout: Inline[]; tone: "info" | "warn" | "danger" }
1818 | { code: string }
1919 | { items: Array<{ mark: "ok" | "fail" | "skip" | "dot"; text: Inline[]; url?: string }> }
20- | { quote: Inline[] };
20+ | { quote: Inline[] }
21+ /** Plain text exactly as someone typed it, lines kept, never read as Markdown or HTML. */
22+ | { plain: string };
2123
2224 export interface Email {
2325 subject: string;
@@ -35,6 +37,8 @@
3537 thread?: string;
3638 /** Main link (also "View it on Diffinery" in the footer). */
3739 url?: string;
40+ /** Where the recipient's reply goes, when it isn't to us (a contact message's confirmed sender). */
41+ replyTo?: { email: string; name?: string };
3842 }
3943
4044 export interface RenderCtx {
@@ -129,6 +133,8 @@
129133 if ("code" in b)
130134 return `<pre class="md" style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${C.codeBg};font-family:${MONO};font-size:12.5px;line-height:1.55;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.code)}</pre>`;
131135 if ("quote" in b) return `<div class="muted" style="margin:0 0 16px;padding:2px 0 2px 14px;border-left:3px solid ${C.border};color:${C.fg2};font-size:14px;line-height:1.6">${inlineHtml(b.quote)}</div>`;
136+ if ("plain" in b)
137+ return `<div class="md box" style="margin:0 0 16px;padding:14px 16px;border:1px solid ${C.border};border-radius:8px;font-size:14px;line-height:1.6;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.plain)}</div>`;
132138 const marks = { ok: ["✓", C.tones.green![1]], fail: ["✕", C.tones.red![1]], skip: ["–", C.fg3], dot: ["•", C.fg3] } as const;
133139 return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.items
134140 .map(
@@ -146,6 +152,7 @@
146152 if ("callout" in b) return `! ${inlineText(b.callout)}`;
147153 if ("code" in b) return b.code.split("\n").map((l) => ` ${l}`).join("\n");
148154 if ("quote" in b) return `> ${inlineText(b.quote)}`;
155+ if ("plain" in b) return b.plain.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
149156 const marks = { ok: "[ok]", fail: "[failed]", skip: "[skipped]", dot: "-" };
150157 return b.items.map((it) => `${marks[it.mark]} ${inlineText(it.text)}${it.url ? ` <${it.url}>` : ""}`).join("\n");
151158 }
@@ -167,6 +174,7 @@
167174 account: "You are receiving this because you have a Diffinery account.",
168175 signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.",
169176 early_access: "You are receiving this because someone asked for early access to Diffinery with this email address. If it wasn't you, you can ignore this email.",
177+ contact: "You are receiving this because someone sent a message through Diffinery's contact form with this email address. If it wasn't you, you can ignore this email.",
170178 digest: "You are receiving this because you turned on the morning digest in your notification settings.",
171179 };
172180
@@ -840,6 +848,54 @@
840848 ],
841849 url: `${pub}/settings/admin`,
842850 };
851+ case "contact_confirm": {
852+ // Nothing typed into the form: the address isn't confirmed yet, so this must not carry anyone's words.
853+ const d = job.data;
854+ return {
855+ subject: "[Diffinery] Confirm your message to the Diffinery team",
856+ preheader: `Confirm before ${day(d.expiresAt)} and it goes to the team. Nothing is sent without it.`,
857+ eyebrow: { text: "Contact", tone: "blue" },
858+ title: ["Confirm your message"],
859+ blocks: [
860+ { p: ["Thanks for writing to us. We received a message sent with this email address through the contact form, under the topic ", { b: d.topic }, ". Confirm that it's yours and it goes to the team, who reply to this address."] },
861+ { button: { label: "Confirm my message", url: d.url } },
862+ { p: ["The link works once and expires ", at(d.expiresAt), ". If you didn't write to us, ignore this email: the message is never read and is deleted after a week. Please don't reply to this email; replies aren't read."], muted: true },
863+ ],
864+ url: d.url,
865+ };
866+ }
867+ case "contact_received": {
868+ const d = job.data;
869+ return {
870+ subject: "[Diffinery] We got your message",
871+ preheader: `Topic: ${d.topic}. The team replies to this address.`,
872+ eyebrow: { text: "Contact", tone: "green" },
873+ title: ["We got your message"],
874+ blocks: [
875+ { p: ["Thanks for writing to us. The team reads every message and replies to this address. Here is a copy of what you sent, under the topic ", { b: d.topic }, ":"] },
876+ { plain: d.message },
877+ { p: ["Reference ", { code: d.id }, ". Please don't reply to this email; replies aren't read. To add something, send another message."], muted: true },
878+ ],
879+ };
880+ }
881+ case "contact_message": {
882+ const d = job.data;
883+ const url = `${pub}/settings/admin/contact/${d.id}`;
884+ return {
885+ subject: `[Diffinery] Contact: ${d.topic} from ${d.name}`,
886+ preheader: d.message.replace(/\s+/g, " ").slice(0, 140),
887+ eyebrow: { text: "Contact message", tone: "blue" },
888+ title: ["New message from ", { b: d.name }],
889+ blocks: [
890+ { kv: [["From", d.login ? [d.name, " (", { b: user(d.login) }, ")"] : d.name], ["Email", d.email], ["Topic", d.topic], ["When", at(job.at)]] },
891+ { plain: d.message },
892+ { button: { label: "Open in Diffinery", url } },
893+ { p: [`Reply to this email to answer ${d.email} from your own mailbox, then mark the message handled in Site administration → Contact messages. The address ${d.login ? "belongs to the sender's account" : "was confirmed through an emailed link"}.`], muted: true },
894+ ],
895+ url,
896+ replyTo: { email: d.email, name: d.name },
897+ };
898+ }
843899 case "security_report": {
844900 // Only the summary: the details stay in Diffinery, behind a site administrator's sign-in.
845901 const url = `${pub}/settings/admin/security-reports/${job.data.id}`;
modifiedapps/diffinery/src/mail/types.ts+14−0View file
@@ -22,6 +22,7 @@
2222 | "account"
2323 | "signup"
2424 | "early_access"
25+ | "contact"
2526 | "digest";
2627
2728 /**
@@ -139,6 +140,15 @@
139140 | { kind: "early_access_remove"; data: { url: string; expiresAt: string } }
140141 | { kind: "early_access_known"; data: { variant: "waiting" | "invited" | "account"; since: string } }
141142 | { kind: "security_report"; data: { id: string; summary: string } }
143+ /**
144+ * The link that confirms a message sent at /contact by someone who isn't signed in (lib/contact.ts). Never carries
145+ * what they typed. `topic` is the topic's label, from the form's fixed list.
146+ */
147+ | { kind: "contact_confirm"; data: { url: string; expiresAt: string; topic: string } }
148+ /** A signed-in person's copy of the message they sent at /contact. */
149+ | { kind: "contact_received"; data: { id: string; topic: string; message: string } }
150+ /** A confirmed contact message, for each site administrator, with the sender as Reply-To. */
151+ | { kind: "contact_message"; data: { id: string; name: string; email: string; login: string | null; topic: string; message: string } }
142152 | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
143153 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
144154 | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } }
@@ -189,12 +199,16 @@
189199 case "early_access_confirm":
190200 case "early_access_known":
191201 case "early_access_remove":
202+ // The sender's own confirmation link or copy of their message.
203+ case "contact_confirm":
204+ case "contact_received":
192205 // It is about the payer's own model spend, so it always sends.
193206 case "review_limit_reached":
194207 case "test":
195208 return "account";
196209 case "invite_accepted":
197210 case "access_request":
211+ case "contact_message":
198212 case "org_membership":
199213 return "orgs";
200214 case "repo_access":
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
@@ -179,7 +179,7 @@
179179 }
180180 />
181181 <PrefRow title="Repositories" hint="Repositories created or deleted in your organizations, and access granted to you." email={{ name: "repos.email", on: p.repos.email }} />
182- <PrefRow title="Organizations" hint="Your organization role, accepted invitations and access requests (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} />
182+ <PrefRow title="Organizations" hint="Your organization role, accepted invitations, and access requests and contact messages (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} />
183183 <PrefRow title="Security and account" hint="New sign-ins, passkeys, tokens, agents, push protection, invitations and sign-in links." email={{ name: "security", on: true, locked: true }} />
184184 </tbody>
185185 </table>
modifiedapps/diffinery/src/web/account.tsx+21−4View file
@@ -23,6 +23,7 @@
2323 import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
2424 import { MAX_AVATAR_BYTES } from "../lib/image.js";
2525 import { gatherToday } from "../agentic/today.js";
26+import { ContactMessages } from "../lib/contact.js";
2627 import { EarlyAccess } from "../lib/early-access.js";
2728 import { COLLAPSED_COOKIE, Rows, Section, TodayView, collapsedSections } from "../agentic/web.js";
2829 import { landingPage } from "./site.js";
@@ -158,7 +159,7 @@
158159 if (kind.endsWith("comment")) return ICONS.comment;
159160 if (kind === "org_membership") return ICONS.org;
160161 if (kind === "repo_access") return ICONS.repo;
161- if (kind === "invitation" || kind === "early_access") return ICONS.envelope;
162+ if (kind === "invitation" || kind === "early_access" || kind === "contact") return ICONS.envelope;
162163 return ICONS.bell;
163164 }
164165
@@ -634,9 +635,9 @@
634635 { value: "rebase", label: "Rebase and merge" },
635636 ];
636637
637-export type SettingsTab = "profile" | "account" | "appearance" | "pull-requests" | "security" | "tokens" | "apps" | "installations" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access" | "search-engines";
638+export type SettingsTab = "profile" | "account" | "appearance" | "pull-requests" | "security" | "tokens" | "apps" | "installations" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access" | "contact" | "search-engines";
638639 /** The site administration pages: they show when hosted compute is paused. */
639-export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status", "search-engines"];
640+export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "contact", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status", "search-engines"];
640641
641642 /** The number of confirmed early access requests waiting, next to Early access in the sidebar (lib/early-access.ts). */
642643 const EarlyAccessWaiting = async ({ db }: { db?: D1Database }) => {
@@ -650,6 +651,18 @@
650651 );
651652 };
652653
654+/** The number of new contact messages, next to Contact messages in the sidebar (lib/contact.ts). */
655+const ContactWaiting = async ({ db }: { db?: D1Database }) => {
656+ const n = db ? await new ContactMessages(db).waiting().catch(() => 0) : 0;
657+ return n ? (
658+ <span class="badge blue" style="margin-left:6px" title={`${n} new message${n === 1 ? "" : "s"}`}>
659+ {n}
660+ </span>
661+ ) : (
662+ <></>
663+ );
664+};
665+
653666 /** On People and invitations: a pointer to the early access requests waiting, when there are any. */
654667 const EarlyAccessCallout = async ({ db }: { db: D1Database }) => {
655668 const n = await new EarlyAccess(db).waiting().catch(() => 0);
@@ -667,7 +680,7 @@
667680 );
668681 };
669682
670-/** The Settings sidebar. `db` lets it show how many early access requests wait (site administrators only). */
683+/** The Settings sidebar. `db` lets it show how many early access requests and contact messages wait (site administrators only). */
671684 export const SettingsNav: FC<{ me: Account; tab: SettingsTab; db?: D1Database }> = ({ me, tab, db }) => (
672685 <aside class="side vt-side">
673686 <section>
@@ -738,6 +751,10 @@
738751 Early access
739752 <EarlyAccessWaiting db={db} />
740753 </a>
754+ <a href="/settings/admin/contact" class={tab === "contact" ? "on" : ""}>
755+ Contact messages
756+ <ContactWaiting db={db} />
757+ </a>
741758 <a href="/settings/admin/reports" class={tab === "reports" ? "on" : ""}>
742759 Reports
743760 </a>
addedapps/diffinery/src/web/contact.tsx+595−0View file
@@ -1,0 +1,595 @@
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} &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+}
modifiedapps/diffinery/src/web/early-access.tsx+8−7View file
@@ -204,7 +204,7 @@
204204 }
205205
206206 /** Runs on the confirmation page: takes the token from the fragment (never sent to the server in a request line) into the form. */
207-const CONFIRM_SCRIPT = `(function(){var f=document.getElementById("ea-confirm"),s=document.getElementById("ea-confirm-status");if(!f||!s)return;function go(){var t=location.hash.slice(1);if(!t){if(!f.elements.token.value){s.textContent="This link is incomplete. Open it again from the email, in full.";}return;}if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);f.elements.token.value=t;s.textContent="";f.style.display="";}go();addEventListener("hashchange",go);})();`;
207+export const CONFIRM_SCRIPT = `(function(){var f=document.getElementById("ea-confirm"),s=document.getElementById("ea-confirm-status");if(!f||!s)return;function go(){var t=location.hash.slice(1);if(!t){if(!f.elements.token.value){s.textContent="This link is incomplete. Open it again from the email, in full.";}return;}if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);f.elements.token.value=t;s.textContent="";f.style.display="";}go();addEventListener("hashchange",go);})();`;
208208
209209 function confirmPage(c: AppContext): Response | Promise<Response> {
210210 return render(
@@ -309,13 +309,14 @@
309309 }
310310
311311 /**
312- * The checks every public form here makes before it does anything: Turnstile is configured, the
313- * honeypot is empty, the timing stamp is genuine and the form wasn't sent faster than a person types
314- * ("silent": answer as if it worked and keep nothing), and the Turnstile token is good.
312+ * The checks every public form here (and the contact form, web/contact.tsx) makes before it does
313+ * anything: Turnstile is configured (else 503 with `missing`), the honeypot is empty, the timing stamp
314+ * is genuine and the form wasn't sent faster than a person types ("silent": answer as if it worked and
315+ * keep nothing), and the Turnstile token is good.
315316 */
316-async function guard(c: AppContext, f: FormData): Promise<"ok" | "silent"> {
317+export async function guard(c: AppContext, f: FormData, missing = TURNSTILE_MISSING): Promise<"ok" | "silent"> {
317318 const mode = turnstileMode(c.env);
318- if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
319+ if (mode === "missing") throw new HttpError(503, missing);
319320 const stamp = await checkStamp(c.env, f.s("stamp"));
320321 if (f.s("website") !== "" || stamp === "fast" || stamp === "bad") return "silent";
321322 if (stamp === "stale") throw new HttpError(422, "This form was open for a long time. Please check it and send it again.");
@@ -324,7 +325,7 @@
324325 }
325326
326327 /** The honeypot, timing stamp and Turnstile widget every public form here carries. */
327-const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => (
328+export const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => (
328329 <>
329330 {/* A field people never see or fill in: a script filling every field gives itself away. */}
330331 <div class="ea-hp" aria-hidden="true">
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
@@ -11,6 +11,7 @@
1111 import { registerTerminals } from "../agentic/terminal-web.js";
1212 import { registerMailWeb } from "../mail/web.js";
1313 import { registerSecurityReports } from "./security-reports.js";
14+import { registerContact } from "./contact.js";
1415 import { registerEarlyAccess } from "./early-access.js";
1516 import { registerStatus } from "./status.js";
1617 import { registerAuth } from "./auth.js";
@@ -80,6 +81,7 @@
8081 registerMailWeb(web);
8182 registerSecurityReports(web);
8283 registerEarlyAccess(web);
84+registerContact(web);
8385 // /status: before the /:owner catch-all.
8486 registerStatus(web);
8587 // Before registerAccount: its /:owner catch-all would otherwise swallow /imports, /new/import and /settings/linked-accounts.
modifiedapps/diffinery/src/web/seo.tsx+5−3View file
@@ -12,7 +12,7 @@
1212 import type { WebApp } from "./ui.js";
1313 import { ICON_FILES } from "./icons.generated.js";
1414 import { docsSitemapEntries } from "../docs/web.js";
15-import { siteSitemapEntries } from "./site.js";
15+import { CONTACT, siteSitemapEntries } from "./site.js";
1616 import { indexNowKey } from "./indexnow.js";
1717
1818 export const SITE = {
@@ -44,7 +44,7 @@
4444 const CACHE = "public, max-age=604800";
4545
4646 /** Paths that are never useful in search results. */
47-const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|-)(\/|$)/;
47+const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|contact\/confirm|-)(\/|$)/;
4848
4949 export function registerSeo(web: WebApp): void {
5050 for (const name of Object.keys(ICON_FILES)) {
@@ -80,7 +80,7 @@
8080 "User-agent: *",
8181 "Content-Signal: search=yes, ai-input=yes, ai-train=yes",
8282 "Allow: /",
83- ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`),
83+ ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/contact/confirm", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`),
8484 "",
8585 `Sitemap: ${origin}/sitemap.xml`,
8686 "",
@@ -105,6 +105,8 @@
105105 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
106106 // The home page, then the product, comparison and company pages (web/site.tsx).
107107 ...siteSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)),
108+ // The contact form (web/contact.tsx), for people and AI assistants looking for how to reach the team.
109+ url(`${origin}${CONTACT.path}`, CONTACT.updated),
108110 ...docsSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)),
109111 ...repos.map((r) => url(`${origin}/${encodeURIComponent(r.owner_login)}/${encodeURIComponent(r.name)}`, r.pushed_at ?? r.updated_at)),
110112 "</urlset>",
modifiedapps/diffinery/src/web/site.tsx+4−0View file
@@ -267,6 +267,9 @@
267267 /** Where to ask to try Diffinery on the project's own instance, whatever instance serves this page (web/early-access.tsx). */
268268 export const EARLY_ACCESS = "https://diffinery.com/early-access";
269269
270+/** The contact form (web/contact.tsx): on every instance, it reaches that instance's site administrators. `updated` is for the sitemap. */
271+export const CONTACT = { path: "/contact", updated: "2026-10-26" };
272+
270273 /** schema.org ids for the site's entities, shared by every page's graph. */
271274 const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` });
272275
@@ -676,6 +679,7 @@
676679 `- [Home page](${o}/index.md): ${HOME.description}`,
677680 `- Open source under the Apache License 2.0 (SPDX: ${SOURCE.spdx}). Source code: ${SOURCE.repo}, mirrored to ${SOURCE.github}. Free, with no paid edition and nothing sold.`,
678681 `- How to use it: deploy your own instance to your own Cloudflare account and domain (Workers Paid plan); you pay Cloudflare for what it uses. Guide: ${o}/docs/self-hosting/deploy.md. Overview: ${o}/docs/self-hosting.md. There's no hosted service for teams to sign up for: diffinery.com is the project's home and its own instance, with accounts by invitation. To try Diffinery before deploying your own, request early access at ${EARLY_ACCESS} (how it works: ${o}/docs/accounts/early-access.md).`,
682+ `- Contact the Diffinery team: ${o}${CONTACT.path} (a form; a person replies by email). Private security reports: ${o}/security/report. Bugs and ideas: issues on ${SOURCE.repo}.`,
679683 `- Runs entirely on Cloudflare (Workers, Durable Objects, R2, D1, Queues, Workflows, Containers): code, pull requests, CI runners and the agent features alike. It is ${CLOUDFLARE_CLAIM} See ${o}/built-on-cloudflare.md, which compares the other Git and CI projects on Cloudflare.`,
680684 "- Hosted CI runners: Ubuntu 24.04 x64 containers, 4 vCPU and 12 GiB of memory by default, with smaller sizes chosen per job from its measured runs. No hosted macOS, Windows or Arm runners; self-hosted runners cover them.",
681685 "- Runs GitHub Actions workflow files from `.github/workflows/` as they are; differences are listed at " + `${o}/docs/actions/compatibility.md`,
modifiedapps/diffinery/src/web/ui.tsx+2−1View file
@@ -476,7 +476,7 @@
476476 { title: "Product", links: [["/features", "Features"], ["/agents", "For AI agents"], ["/performance", "Performance"], ["/security", "Security"], ["/built-on-cloudflare", "Built on Cloudflare"], ["/open-source", "Open source"], ["/docs/migrate-from-github", "Migrate from GitHub"]] },
477477 { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] },
478478 { title: "Resources", links: [["/docs", "Docs"], ["/docs/self-hosting", "Self-hosting"], ["/docs/guides", "Guides"], ["/docs/connect", "Connect AI apps"], ["/llms.txt", "llms.txt"], ["/status", "Status"], ["/docs/security/disclosure", "Report a vulnerability"]] },
479- { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/privacy", "Privacy"]] },
479+ { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/contact", "Contact"], ["/privacy", "Privacy"]] },
480480 ].map((col) => (
481481 <nav aria-label={col.title}>
482482 <h2>{col.title}</h2>
@@ -619,6 +619,7 @@
619619 <a href="/docs">Docs</a>
620620 <a href="/docs/security/disclosure">Security</a>
621621 <a href="/status">Status</a>
622+ <a href="/contact">Contact</a>
622623 </nav>
623624 ) : (
624625 <a href="/built-on-cloudflare">Runs entirely on Cloudflare</a>