From 97a4878567fbee7b2065f8bff89bcf198797867d Mon Sep 17 00:00:00 2001
From: Joel Borchardt <121824267+joelborch@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:45:51 -0400
Subject: [PATCH 1/6] Let an allowed api key email a comment back to an email
ticket's requester
A comment posted with emailToRequester also goes to the sender whose email
opened the ticket, in their thread. The DO records who it went to on the
comment (emailTo, emailStatus "sending") and queues an email-reply side
effect carrying the intake alias, a "Re:
" subject and the
ticket's Message-IDs. It accepts the option from a person in the app or
from an api key an owner or admin allowed (ApiKey.canEmailRequesters,
set with PATCH /api/api-keys/:id), never over MCP, and only while
EMAIL_INTAKE_REPLY is "true". Unverified senders can be answered too:
intake only files allowlisted senders, and the reply goes to that address,
so someone forging it never receives it. markEmailReply moves a sending
comment to sent or failed once.
Migration core-0014-email-replies adds the comment columns,
api_keys.can_email_requesters and email_messages.authenticated (backfilled
from each message's email.ingest audit row). TaskDetail gains
emailReplyTo and emailReplySubject so a client can preview the email.
Co-Authored-By: Claude Opus 5.5
---
apps/importer/src/transform.ts | 2 +
apps/web/src/store/apply.test.ts | 2 +-
apps/web/src/store/mutations.ts | 5 +
packages/core/package.json | 3 +-
packages/core/src/automation/types.ts | 18 +
packages/core/src/email-intake.ts | 35 ++
packages/core/src/email-reply-storage.test.ts | 362 ++++++++++++++++++
packages/core/src/handoffs.test.ts | 2 +-
packages/core/src/index.ts | 199 +++++++++-
packages/core/src/migrations.test.ts | 63 +++
packages/core/src/rows.ts | 16 +-
packages/core/src/schema.ts | 28 ++
packages/shared/src/api.ts | 7 +
packages/shared/src/entities.ts | 17 +
packages/shared/src/events.ts | 11 +
15 files changed, 749 insertions(+), 21 deletions(-)
create mode 100644 packages/core/src/email-reply-storage.test.ts
diff --git a/apps/importer/src/transform.ts b/apps/importer/src/transform.ts
index a5b6d18..79576b5 100644
--- a/apps/importer/src/transform.ts
+++ b/apps/importer/src/transform.ts
@@ -620,6 +620,8 @@ export function buildComments(
body,
createdAt: parseTs(c.date) ?? opts.idMap.importedAt,
via: "import",
+ emailTo: null,
+ emailStatus: null,
});
}
}
diff --git a/apps/web/src/store/apply.test.ts b/apps/web/src/store/apply.test.ts
index 7ae798e..de4e098 100644
--- a/apps/web/src/store/apply.test.ts
+++ b/apps/web/src/store/apply.test.ts
@@ -70,7 +70,7 @@ function subtask(id: string, taskId: string, patch: Partial = {}): Subt
}
function detail(t: Task, extra: Partial = {}): TaskDetail {
- return { task: t, subtasks: [], comments: [], attachments: [], ...extra };
+ return { task: t, subtasks: [], comments: [], attachments: [], emailReplyTo: null, emailReplySubject: null, ...extra };
}
let seq = 10;
diff --git a/apps/web/src/store/mutations.ts b/apps/web/src/store/mutations.ts
index 2f299d4..bed433b 100644
--- a/apps/web/src/store/mutations.ts
+++ b/apps/web/src/store/mutations.ts
@@ -285,6 +285,8 @@ export async function addComment(taskId: string, body: string): Promise
body,
createdAt: Date.now(),
via: "ui",
+ emailTo: null,
+ emailStatus: null,
};
const put = (list: Comment[]): void => {
const next = new Map(comments.value);
@@ -494,6 +496,9 @@ async function loadTaskDetail(taskId: string): Promise {
subtasks: subtasks.value.get(taskId) ?? [],
comments: comments.value.get(taskId) ?? [],
attachments: [],
+ // Unknown offline; only the CLI reads these anyway.
+ emailReplyTo: null,
+ emailReplySubject: null,
};
}
}
diff --git a/packages/core/package.json b/packages/core/package.json
index 97006d8..c172dea 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -8,7 +8,8 @@
"exports": {
".": "./src/index.ts",
"./automation": "./src/automation/index.ts",
- "./id": "./src/id.ts"
+ "./id": "./src/id.ts",
+ "./email-intake": "./src/email-intake.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
diff --git a/packages/core/src/automation/types.ts b/packages/core/src/automation/types.ts
index 0e9d5c7..787d921 100644
--- a/packages/core/src/automation/types.ts
+++ b/packages/core/src/automation/types.ts
@@ -73,6 +73,24 @@ export type SideEffectPayload =
body: string;
ruleId: string;
taskId: string;
+ }
+ | {
+ /**
+ * A comment a person chose to email back to the ticket's requester (see
+ * Workspace.createComment). The consumer sends it in the requester's
+ * thread and reports back through markEmailReply.
+ */
+ kind: "email-reply";
+ to: string;
+ /** The intake alias; the consumer adds EMAIL_INTAKE_DOMAIN for Reply-To. */
+ alias: string;
+ subject: string;
+ /** The comment's markdown, exactly as it shows on the card. */
+ body: string;
+ inReplyTo: string | null;
+ references: string[];
+ taskId: string;
+ commentId: string;
};
/**
diff --git a/packages/core/src/email-intake.ts b/packages/core/src/email-intake.ts
index ed9e83a..2586513 100644
--- a/packages/core/src/email-intake.ts
+++ b/packages/core/src/email-intake.ts
@@ -21,6 +21,41 @@ export const EMAIL_INTAKE_TAG = "email";
*/
export const EMAIL_INTAKE_UNVERIFIED_TAG = "unverified";
+/**
+ * Prefix of the dedupe key the Worker mints for a message that arrived without
+ * a Message-ID. Such a key names no real message, so nothing threads onto it.
+ */
+export const SYNTHETIC_ID_PREFIX = "sha256:";
+
+/** Where a comment emailed back to a ticket's requester goes, and what it threads onto. */
+export interface EmailReplyTarget {
+ /** The authenticated sender whose email opened the ticket. */
+ to: string;
+ /** The intake alias it arrived on; the Worker adds EMAIL_INTAKE_DOMAIN for Reply-To. */
+ alias: string;
+ /** The ticket's newest real Message-ID, for In-Reply-To. Null when none was real. */
+ inReplyTo: string | null;
+ /** Every real Message-ID filed on the ticket, oldest first, for References. */
+ references: string[];
+}
+
+/** Most messages a reply's References carries, oldest kept first like the Worker's header cap. */
+export const EMAIL_REPLY_REFERENCE_LIMIT = 20;
+
+/**
+ * "Re: " for a reply, so the requester's mail client files it under
+ * their original message. Earlier "Re:"/"Fwd:" prefixes come off first, and so
+ * do line breaks, which a header can't carry.
+ */
+export function replySubject(title: string): string {
+ const base = title
+ .replace(/[\r\n]+/g, " ")
+ .replace(/^(\s*(re|fwd?|aw)\s*:\s*)+/i, "")
+ .trim()
+ .slice(0, 200);
+ return base === "" ? "Re: your request" : `Re: ${base}`;
+}
+
/** An attachment the Worker has already written to R2. */
export interface EmailIngestAttachment {
id: string;
diff --git a/packages/core/src/email-reply-storage.test.ts b/packages/core/src/email-reply-storage.test.ts
new file mode 100644
index 0000000..eddeddb
--- /dev/null
+++ b/packages/core/src/email-reply-storage.test.ts
@@ -0,0 +1,362 @@
+import { DatabaseSync } from "node:sqlite";
+import { describe, expect, it, vi } from "vitest";
+import {
+ type Actor,
+ CreateCommentInput,
+ CreateListInput,
+ CreateSpaceInput,
+ CreateTaskInput,
+ type ServerMsg,
+} from "@flow/shared";
+import type { SideEffectPayload } from "./automation/types.js";
+import type { IngestEmailInput } from "./email-intake.js";
+
+// ---------------------------------------------------------------------------
+// Email replies against the real Workspace class: a person in the app (or an
+// allowed api key) emails a comment back to the requester of an emailed
+// ticket, verified or not. Covers what the comment records and what gets
+// queued (recipient, Reply-To alias, the "Re:" subject, the thread's
+// Message-IDs), TaskDetail.emailReplyTo, every refusal (a key nobody allowed,
+// MCP, replies off, a task that never came in by email, a removed alias), the
+// key permission itself, and markEmailReply's one-way status moves.
+//
+// Same real-Workspace-over-node:sqlite harness as email-intake-storage.test.ts.
+// ---------------------------------------------------------------------------
+
+vi.mock("cloudflare:workers", () => ({
+ DurableObject: class {
+ ctx: unknown;
+ env: unknown;
+ constructor(ctx: unknown, env: unknown) {
+ this.ctx = ctx;
+ this.env = env;
+ }
+ },
+}));
+
+function sqliteStorage(db: DatabaseSync): SqlStorage {
+ const exec = (query: string, ...params: unknown[]): unknown => {
+ const returnsRows = /^\s*SELECT/i.test(query) || /RETURNING/i.test(query);
+ if (!returnsRows) {
+ db.prepare(query).run(...(params as never[]));
+ return { toArray: () => [], one: () => undefined };
+ }
+ const rows = db.prepare(query).all(...(params as never[]));
+ return {
+ toArray: () => rows,
+ one: () => {
+ if (rows.length !== 1) throw new Error(`expected 1 row, got ${rows.length}`);
+ return rows[0];
+ },
+ };
+ };
+ return { exec } as unknown as SqlStorage;
+}
+
+function fakeCtx(db: DatabaseSync, sockets: WebSocket[]): DurableObjectState {
+ return {
+ id: { toString: () => "do_test" },
+ storage: {
+ sql: sqliteStorage(db),
+ getAlarm: async () => null,
+ setAlarm: async () => undefined,
+ transactionSync: (fn: () => T): T => {
+ db.prepare("SAVEPOINT _txn").run();
+ try {
+ const result = fn();
+ db.prepare("RELEASE _txn").run();
+ return result;
+ } catch (err) {
+ db.prepare("ROLLBACK TO _txn").run();
+ db.prepare("RELEASE _txn").run();
+ throw err;
+ }
+ },
+ },
+ blockConcurrencyWhile: async (fn: () => Promise | T): Promise => fn(),
+ waitUntil: () => undefined,
+ getWebSockets: () => sockets,
+ acceptWebSocket: (ws: WebSocket) => {
+ sockets.push(ws);
+ },
+ } as unknown as DurableObjectState;
+}
+
+const OWNER = "us_owner";
+/** What the Worker's email() handler passes. */
+const MAIL: Actor = { userId: OWNER, via: "webhook", apiKeyId: null, automationRuleId: null };
+/** A person signed in to the web app. */
+const PERSON: Actor = { userId: OWNER, via: "ui", apiKeyId: null, automationRuleId: null };
+const API_KEY: Actor = { userId: OWNER, via: "api", apiKeyId: "ak_1", automationRuleId: null };
+const MCP: Actor = { userId: OWNER, via: "mcp", apiKeyId: "ak_1", automationRuleId: null };
+
+async function seeded(env: { EMAIL_INTAKE_REPLY?: string } = { EMAIL_INTAKE_REPLY: "true" }) {
+ const { Workspace } = await import("./index.js");
+ const db = new DatabaseSync(":memory:");
+ const sockets: WebSocket[] = [];
+ const sendBatch = vi.fn(async (_batch: Array<{ body: unknown }>) => undefined);
+ const ws = new Workspace(fakeCtx(db, sockets), { SIDE_EFFECTS: { sendBatch }, ...env } as never);
+
+ ws.upsertUser({ id: OWNER, email: "owner@example.com", name: "Owner", role: "owner" }, OWNER);
+ const space = ws.createSpace(CreateSpaceInput.parse({ name: "Ops", visibility: "workspace" }), OWNER);
+ const support = ws.createList(CreateListInput.parse({ spaceId: space.id, name: "Support" }), OWNER);
+ ws.setEmailIntake(support.id, { alias: "support", allowedSenders: ["@clinic.com"] }, OWNER);
+
+ const frames: ServerMsg[] = [];
+ sockets.push({
+ deserializeAttachment: () => ({ userId: OWNER }),
+ serializeAttachment: () => undefined,
+ send: (payload: string) => frames.push(JSON.parse(payload) as ServerMsg),
+ } as unknown as WebSocket);
+
+ let n = 0;
+ const mail = (over: Partial = {}): IngestEmailInput => {
+ n += 1;
+ return {
+ alias: "support",
+ sender: "pat@clinic.com",
+ authenticated: true,
+ messageId: `m${n}@clinic.com`,
+ referenceIds: [],
+ task: { title: `Ticket ${n}`, description: `Body ${n}` },
+ commentBody: `Reply ${n}`,
+ attachments: [],
+ ...over,
+ };
+ };
+
+ /** A ticket opened by `mail(over)`; returns its task id. */
+ const ticket = (over: Partial = {}): string => {
+ const result = ws.ingestEmail(mail(over), MAIL);
+ if (result.status !== "created") throw new Error(`expected a new ticket, got ${result.status}`);
+ return result.taskId;
+ };
+
+ const queued = (): SideEffectPayload[] =>
+ sendBatch.mock.calls.flatMap(([batch]) => batch.map((m) => m.body as SideEffectPayload));
+ const replies = () => queued().filter((p) => p.kind === "email-reply");
+ const comment = (taskId: string, body: string, emailToRequester?: boolean) =>
+ CreateCommentInput.parse({ taskId, body, ...(emailToRequester === undefined ? {} : { emailToRequester }) });
+ const commentCount = (): number =>
+ (db.prepare("SELECT COUNT(*) AS n FROM comments").all()[0] as { n: number }).n;
+ const auditActions = (): string[] =>
+ (db.prepare("SELECT action FROM audit ORDER BY id").all() as Array<{ action: string }>).map((r) => r.action);
+
+ /** A key acting as the owner; `allowed` turns on canEmailRequesters the way the app does. */
+ const key = (allowed: boolean) => {
+ const created = ws.createApiKey({ name: "cli", userId: OWNER, tokenHash: `hash_${++n}` }, OWNER);
+ if (allowed) ws.setApiKeyEmailRequesters(created.id, true, PERSON);
+ return created;
+ };
+ const viaKey = (keyId: string, via: "api" | "mcp" = "api"): Actor => ({
+ userId: OWNER,
+ via,
+ apiKeyId: keyId,
+ automationRuleId: null,
+ });
+
+ return { ws, db, frames, support, mail, ticket, replies, comment, commentCount, auditActions, key, viaKey };
+}
+
+describe("createComment with emailToRequester", () => {
+ it("records the recipient on the comment and queues the reply in the requester's thread", async () => {
+ const { ws, ticket, mail, replies, comment, auditActions } = await seeded();
+ const taskId = ticket({ messageId: "first@clinic.com", task: { title: "Re: Printer jammed", description: "Help" } });
+ // A follow-up in the same thread, so the reply answers the newest message.
+ ws.ingestEmail(mail({ messageId: "second@clinic.com", referenceIds: ["first@clinic.com"] }), MAIL);
+
+ const created = ws.createComment(comment(taskId, "Done, here it is: https://example.com/page", true), PERSON);
+ expect(created).toMatchObject({ emailTo: "pat@clinic.com", emailStatus: "sending", via: "ui" });
+ expect(ws.getTaskDetail(taskId).comments.find((c) => c.id === created.id)).toMatchObject({
+ emailTo: "pat@clinic.com",
+ emailStatus: "sending",
+ });
+
+ expect(replies()).toEqual([
+ {
+ kind: "email-reply",
+ to: "pat@clinic.com",
+ alias: "support",
+ subject: "Re: Printer jammed",
+ body: "Done, here it is: https://example.com/page",
+ inReplyTo: "second@clinic.com",
+ references: ["first@clinic.com", "second@clinic.com"],
+ taskId,
+ commentId: created.id,
+ },
+ ]);
+ expect(auditActions()).toContain("comment.email_reply");
+ });
+
+ it("leaves an ordinary comment internal: nothing recorded, nothing queued", async () => {
+ const { ws, ticket, replies, comment } = await seeded();
+ const taskId = ticket();
+ for (const flag of [undefined, false]) {
+ const created = ws.createComment(comment(taskId, "Internal note", flag), PERSON);
+ expect(created).toMatchObject({ emailTo: null, emailStatus: null });
+ }
+ expect(replies()).toEqual([]);
+ });
+
+ it("threads onto nothing when the ticket had no real Message-ID", async () => {
+ const { ws, ticket, replies, comment } = await seeded();
+ const taskId = ticket({ messageId: "sha256:abc123" });
+ ws.createComment(comment(taskId, "Done", true), PERSON);
+ expect(replies()[0]).toMatchObject({ inReplyTo: null, references: [] });
+ });
+
+ it("is sent with an api key an owner allowed, but never through MCP or a revoked key", async () => {
+ const { ws, ticket, replies, comment, key, viaKey } = await seeded();
+ const taskId = ticket();
+ const allowed = key(true);
+
+ const sent = ws.createComment(comment(taskId, "Done, from the CLI", true), viaKey(allowed.id));
+ expect(sent).toMatchObject({ emailTo: "pat@clinic.com", emailStatus: "sending", via: "api" });
+ expect(replies()).toHaveLength(1);
+
+ expect(() => ws.createComment(comment(taskId, "Done", true), viaKey(allowed.id, "mcp"))).toThrow(
+ /allowed to email requesters/
+ );
+ ws.revokeApiKey(allowed.id, OWNER);
+ expect(() => ws.createComment(comment(taskId, "Done", true), viaKey(allowed.id))).toThrow(
+ /allowed to email requesters/
+ );
+ expect(replies()).toHaveLength(1);
+ });
+
+ it("is refused for an api key and for MCP, before anything is written", async () => {
+ const { ws, ticket, replies, comment, commentCount } = await seeded();
+ const taskId = ticket();
+ for (const actor of [API_KEY, MCP]) {
+ expect(() => ws.createComment(comment(taskId, "From an agent", true), actor)).toThrow(
+ /allowed to email requesters/
+ );
+ }
+ expect(commentCount()).toBe(0);
+ expect(replies()).toEqual([]);
+ });
+
+ it("is refused while replies are turned off", async () => {
+ const { ws, ticket, comment, commentCount } = await seeded({});
+ const taskId = ticket();
+ expect(() => ws.createComment(comment(taskId, "Done", true), PERSON)).toThrow(/turned off/);
+ expect(commentCount()).toBe(0);
+ });
+
+ it("answers an unverified sender too, at the address the ticket came from", async () => {
+ const { ws, ticket, replies, comment } = await seeded();
+ const taskId = ticket({ authenticated: false, sender: "dana@clinic.com" });
+ const created = ws.createComment(comment(taskId, "Done", true), PERSON);
+ expect(created).toMatchObject({ emailTo: "dana@clinic.com", emailStatus: "sending" });
+ expect(replies()).toEqual([expect.objectContaining({ to: "dana@clinic.com", commentId: created.id })]);
+ });
+
+ it("is refused for a task that never came in by email, and after its intake alias is removed", async () => {
+ const { ws, ticket, support, comment, commentCount } = await seeded();
+ const typed = ws.createTask(CreateTaskInput.parse({ listId: support.id, title: "Typed in" }), PERSON);
+ expect(() => ws.createComment(comment(typed.id, "Done", true), PERSON)).toThrow(/no email sender to reply to/);
+
+ const orphaned = ticket();
+ ws.setEmailIntake(support.id, null, OWNER);
+ expect(() => ws.createComment(comment(orphaned, "Done", true), PERSON)).toThrow(/no email sender to reply to/);
+ expect(commentCount()).toBe(0);
+ });
+});
+
+describe("TaskDetail.emailReplyTo", () => {
+ it("names the requester, verified or not, and is null for a task that can't be answered", async () => {
+ const { ws, ticket, support } = await seeded();
+ expect(ws.getTaskDetail(ticket({ task: { title: "Fwd: Hours", description: "x" } }))).toMatchObject({
+ emailReplyTo: "pat@clinic.com",
+ emailReplySubject: "Re: Hours",
+ });
+ expect(ws.getTaskDetail(ticket({ authenticated: false, task: { title: "Hours", description: "x" } }))).toMatchObject({
+ emailReplyTo: "pat@clinic.com",
+ emailReplySubject: "Re: Hours",
+ });
+ const typed = ws.createTask(CreateTaskInput.parse({ listId: support.id, title: "Typed in" }), PERSON);
+ expect(ws.getTaskDetail(typed.id)).toMatchObject({ emailReplyTo: null, emailReplySubject: null });
+ });
+
+ it("is null while replies are turned off", async () => {
+ const { ws, ticket } = await seeded({ EMAIL_INTAKE_REPLY: "false" });
+ expect(ws.getTaskDetail(ticket()).emailReplyTo).toBeNull();
+ });
+});
+
+describe("markEmailReply", () => {
+ it("moves a sending comment to sent once, broadcasting the update", async () => {
+ const { ws, frames, ticket, comment, auditActions } = await seeded();
+ const taskId = ticket();
+ const created = ws.createComment(comment(taskId, "Done", true), PERSON);
+ frames.length = 0;
+
+ expect(ws.markEmailReply(created.id, "sent")).toEqual({ ok: true });
+ expect(ws.getTaskDetail(taskId).comments[0]).toMatchObject({ emailStatus: "sent" });
+ const broadcast = frames.flatMap((f) => (f.type === "deltas" ? f.deltas : []));
+ expect(broadcast).toEqual([
+ expect.objectContaining({
+ entity: "comment",
+ op: "update",
+ id: created.id,
+ data: expect.objectContaining({ taskId, emailStatus: "sent", emailTo: "pat@clinic.com" }),
+ }),
+ ]);
+ expect(auditActions()).toContain("comment.email_status");
+
+ // Settled: a late "failed" or a repeat can't move it again.
+ expect(ws.markEmailReply(created.id, "failed")).toEqual({ ok: false });
+ expect(ws.markEmailReply(created.id, "sent")).toEqual({ ok: false });
+ expect(ws.getTaskDetail(taskId).comments[0]?.emailStatus).toBe("sent");
+ });
+
+ it("records a failure, and ignores internal and unknown comments", async () => {
+ const { ws, ticket, comment } = await seeded();
+ const taskId = ticket();
+ const emailed = ws.createComment(comment(taskId, "Done", true), PERSON);
+ const internal = ws.createComment(comment(taskId, "Note"), PERSON);
+
+ expect(ws.markEmailReply(emailed.id, "failed")).toEqual({ ok: true });
+ expect(ws.markEmailReply(internal.id, "sent")).toEqual({ ok: false });
+ expect(ws.markEmailReply("cm_missing", "sent")).toEqual({ ok: false });
+ const byId = new Map(ws.getTaskDetail(taskId).comments.map((c) => [c.id, c]));
+ expect(byId.get(emailed.id)?.emailStatus).toBe("failed");
+ expect(byId.get(internal.id)).toMatchObject({ emailTo: null, emailStatus: null });
+ });
+});
+
+describe("setApiKeyEmailRequesters", () => {
+ it("lets an owner allow and stop a key, and lists the setting", async () => {
+ const { ws, key, auditActions } = await seeded();
+ const created = key(false);
+ expect(created.canEmailRequesters).toBe(false);
+
+ expect(ws.setApiKeyEmailRequesters(created.id, true, PERSON).canEmailRequesters).toBe(true);
+ expect(ws.listApiKeys().find((k) => k.id === created.id)?.canEmailRequesters).toBe(true);
+ expect(ws.setApiKeyEmailRequesters(created.id, false, PERSON).canEmailRequesters).toBe(false);
+ expect(ws.listApiKeys().find((k) => k.id === created.id)?.canEmailRequesters).toBe(false);
+ expect(auditActions().filter((a) => a === "api_key.email_requesters")).toHaveLength(2);
+ });
+
+ it("works from an owner's api key (the CLI), but never over MCP", async () => {
+ const { ws, key, viaKey } = await seeded();
+ const cli = key(false);
+ expect(ws.setApiKeyEmailRequesters(cli.id, true, viaKey(cli.id)).canEmailRequesters).toBe(true);
+ expect(() => ws.setApiKeyEmailRequesters(cli.id, false, viaKey(cli.id, "mcp"))).toThrow(/over MCP/);
+ expect(ws.listApiKeys().find((k) => k.id === cli.id)?.canEmailRequesters).toBe(true);
+ });
+
+ it("can't be granted by a member, or to a revoked or unknown key", async () => {
+ const { ws, key } = await seeded();
+ const other = key(false);
+
+ ws.upsertUser({ id: "us_member", email: "member@example.com", name: "Member", role: "member" }, OWNER);
+ const member: Actor = { userId: "us_member", via: "ui", apiKeyId: null, automationRuleId: null };
+ expect(() => ws.setApiKeyEmailRequesters(other.id, true, member)).toThrow(/owner or admin/);
+
+ expect(() => ws.setApiKeyEmailRequesters("ak_missing", true, PERSON)).toThrow(/not found/);
+ ws.revokeApiKey(other.id, OWNER);
+ expect(() => ws.setApiKeyEmailRequesters(other.id, true, PERSON)).toThrow(/revoked/);
+ expect(ws.listApiKeys().find((k) => k.id === other.id)?.canEmailRequesters).toBe(false);
+ });
+});
diff --git a/packages/core/src/handoffs.test.ts b/packages/core/src/handoffs.test.ts
index a5add4a..38f2a7f 100644
--- a/packages/core/src/handoffs.test.ts
+++ b/packages/core/src/handoffs.test.ts
@@ -142,7 +142,7 @@ describe("row mapping", () => {
it("maps comment via, reading null and unknown values as unrecorded", () => {
const comment = (via: string | null) =>
- toComment({ id: "cm_1", task_id: "tk_1", author_id: "us_a", body: "b", created_at: 0, via });
+ toComment({ id: "cm_1", task_id: "tk_1", author_id: "us_a", body: "b", created_at: 0, via, email_to: null, email_status: null });
expect(comment("mcp").via).toBe("mcp");
expect(comment(null).via).toBeNull();
expect(comment("carrier pigeon").via).toBeNull();
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index f7515bb..7532de0 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -68,11 +68,15 @@ import {
EMAIL_INTAKE_LIST_HOURLY_LIMIT,
EMAIL_INTAKE_TAG,
EMAIL_INTAKE_UNVERIFIED_TAG,
+ EMAIL_REPLY_REFERENCE_LIMIT,
+ SYNTHETIC_ID_PREFIX,
type EmailIngestAttachment,
type EmailIntakeCheck,
+ type EmailReplyTarget,
type IngestEmailInput,
type IngestEmailResult,
normalizeMessageId,
+ replySubject,
senderAllowed,
} from "./email-intake.js";
import {
@@ -188,7 +192,8 @@ export type {
} from "./email-intake.js";
export {
EMAIL_INTAKE_HOURLY_LIMIT, EMAIL_INTAKE_LIST_HOURLY_LIMIT, EMAIL_INTAKE_TAG, EMAIL_INTAKE_UNVERIFIED_TAG,
- messageIdsIn, normalizeMessageId, senderAllowed,
+ EMAIL_REPLY_REFERENCE_LIMIT, SYNTHETIC_ID_PREFIX, messageIdsIn, normalizeMessageId, replySubject,
+ senderAllowed, type EmailReplyTarget,
} from "./email-intake.js";
export { ID_PREFIX, INBOUND_TOKEN_PREFIX, id, token } from "./id.js";
export { HANDOFF_TAG, OPEN_HANDOFF_STATES } from "./handoffs.js";
@@ -205,6 +210,8 @@ export interface Env {
*/
APP_HOSTNAME?: string;
EMAIL_DRY_RUN?: string;
+ /** "true" lets a person email a comment back to a ticket's requester. Off when unset. */
+ EMAIL_INTAKE_REPLY?: string;
}
// Queues' per-message (128KB) and per-sendBatch (100 msgs / 256KB) limits:
@@ -343,6 +350,12 @@ const LIST_COLUMNS = `id, space_id, name, position, archived, inbound_token, cre
const EMAIL_INTAKE_COLUMNS = `list_id, alias, allowed_senders, created_at, updated_at`;
+/** Every api key read goes through this; can_email_requesters is core-0014-email-replies. */
+const API_KEY_COLUMNS = `id, user_id, name, token_hash, created_at, last_used_at, revoked_at, can_email_requesters`;
+
+/** Every comment read goes through this; `email_*` is core-0014-email-replies. */
+const COMMENT_COLUMNS = `id, task_id, author_id, body, created_at, via, email_to, email_status`;
+
/** The window both email rate limits count over. */
const EMAIL_RATE_WINDOW_MS = 3_600_000;
@@ -1047,11 +1060,18 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
* Same ok:false row the queue consumer writes for a failed delivery, for
* side effects that never made it onto the queue at all. Only automation
* payloads have a rule to log against; notification emails (ruleId
- * "notify:*") get the console line alone.
+ * "notify:*") get the console line alone. An emailed comment that never
+ * reached the queue is marked failed on its card, in a turn of its own
+ * because this can run while the turn that wrote it is still flushing.
*/
private recordEnqueueFailures(payloads: readonly unknown[], detail: string): void {
for (const payload of payloads) {
- const p = payload as { kind?: unknown; ruleId?: unknown; taskId?: unknown; url?: unknown };
+ const p = payload as { kind?: unknown; ruleId?: unknown; taskId?: unknown; url?: unknown; commentId?: unknown };
+ if (p.kind === "email-reply" && typeof p.commentId === "string") {
+ const commentId = p.commentId;
+ this.ctx.waitUntil(Promise.resolve().then(() => this.markEmailReply(commentId, "failed")));
+ continue;
+ }
if (p.kind !== "webhook" && p.kind !== "email") continue;
if (typeof p.ruleId !== "string" || typeof p.taskId !== "string") continue;
if (p.ruleId.startsWith("notify:")) continue;
@@ -1633,11 +1653,15 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
if (forUserId !== undefined) {
this.assertSpaceWritable(toActor(forUserId), this.spaceIdForTask(taskId));
}
+ const task = toTask(row);
+ const replyTo = this.emailReplyTarget(task)?.to ?? null;
return {
- task: toTask(row),
+ task,
subtasks: this.listSubtasks(taskId),
comments: this.listComments(taskId),
attachments: this.listAttachments(taskId),
+ emailReplyTo: replyTo,
+ emailReplySubject: replyTo === null ? null : replySubject(task.title),
};
}
@@ -1714,7 +1738,7 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
listComments(taskId: string): Comment[] {
return this.sql
.exec(
- "SELECT id, task_id, author_id, body, created_at, via FROM comments WHERE task_id = ? ORDER BY created_at",
+ `SELECT ${COMMENT_COLUMNS} FROM comments WHERE task_id = ? ORDER BY created_at`,
taskId
)
.toArray()
@@ -1907,8 +1931,7 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
resolveApiKey(tokenHash: string): { key: ApiKey; user: User } | null {
const rows = this.sql
.exec(
- `SELECT id, user_id, name, token_hash, created_at, last_used_at, revoked_at
- FROM api_keys WHERE token_hash = ?`,
+ `SELECT ${API_KEY_COLUMNS} FROM api_keys WHERE token_hash = ?`,
tokenHash
)
.toArray();
@@ -1931,8 +1954,7 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
listApiKeys(): ApiKey[] {
return this.sql
.exec(
- `SELECT id, user_id, name, token_hash, created_at, last_used_at, revoked_at
- FROM api_keys ORDER BY created_at`
+ `SELECT ${API_KEY_COLUMNS} FROM api_keys ORDER BY created_at`
)
.toArray()
.map(toApiKey);
@@ -2476,12 +2498,123 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
// A missing task resolves to no space, so this passes and the not-found
// error comes from applyCommentCreate's requireTaskRow instead.
this.assertTaskWritable(t.actor, input.taskId);
- return this.applyCommentCreate(t, input);
+ // Every reply check runs before the comment is written, since a throw
+ // does not roll back what the turn already wrote.
+ const reply = input.emailToRequester === true ? this.requireEmailReply(t.actor, input.taskId) : null;
+ const comment = this.applyCommentCreate(t, input, reply?.target.to ?? null);
+ if (reply !== null) {
+ const payload: SideEffectPayload = {
+ kind: "email-reply",
+ to: reply.target.to,
+ alias: reply.target.alias,
+ subject: replySubject(reply.title),
+ body: comment.body,
+ inReplyTo: reply.target.inReplyTo,
+ references: reply.target.references,
+ taskId: comment.taskId,
+ commentId: comment.id,
+ };
+ t.enqueue(payload);
+ t.audit("comment.email_reply", comment.id, { taskId: comment.taskId, to: reply.target.to });
+ }
+ return comment;
+ });
+ }
+
+ /**
+ * The checks for emailing a comment back: a person in the web app, or an api
+ * key an owner or admin allowed to (never MCP, and never a key nobody
+ * allowed, so no agent writes to a client on its own), replies turned on,
+ * and a requester to write to.
+ */
+ private requireEmailReply(actor: Actor, taskId: string): { target: EmailReplyTarget; title: string } {
+ if (!this.mayEmailRequesters(actor)) {
+ throw new Error(
+ "Email replies need an API key allowed to email requesters (PATCH /api/api-keys/:id), and never go over MCP."
+ );
+ }
+ if (this.env.EMAIL_INTAKE_REPLY !== "true") throw new Error("Email replies are turned off.");
+ const task = toTask(this.requireTaskRow(taskId));
+ const target = this.emailReplyTarget(task);
+ if (target === null) {
+ throw new Error(`Task ${taskId} has no email sender to reply to.`);
+ }
+ return { target, title: task.title };
+ }
+
+ private mayEmailRequesters(actor: Actor): boolean {
+ if (actor.via === "ui") return actor.apiKeyId === null;
+ if (actor.via !== "api" || actor.apiKeyId === null) return false;
+ const row = this.sql
+ .exec<{ can_email_requesters: number; revoked_at: number | null }>(
+ "SELECT can_email_requesters, revoked_at FROM api_keys WHERE id = ?",
+ actor.apiKeyId
+ )
+ .toArray()[0];
+ return row !== undefined && row.revoked_at === null && row.can_email_requesters === 1;
+ }
+
+ /**
+ * Where an emailed comment on this task would go, or null when it can't
+ * have one: replies are off, the task wasn't opened by email, or the alias it
+ * came in on no longer exists to take the requester's answer. An unverified
+ * sender is answered too: intake only files mail whose envelope sender is on
+ * the list's allowlist, and the reply goes to that address, so someone
+ * forging it never sees the reply; its real owner does.
+ */
+ private emailReplyTarget(task: Task): EmailReplyTarget | null {
+ if (this.env.EMAIL_INTAKE_REPLY !== "true") return null;
+ const rows = this.sql
+ .exec<{ message_id: string; alias: string; sender: string; comment_id: string | null }>(
+ `SELECT message_id, alias, sender, comment_id FROM email_messages
+ WHERE task_id = ? ORDER BY received_at, rowid`,
+ task.id
+ )
+ .toArray();
+ const opener = rows.find((r) => r.comment_id === null);
+ if (!opener) return null;
+ const live = this.sql
+ .exec("SELECT 1 AS ok FROM email_intakes WHERE alias = ?", opener.alias)
+ .toArray().length > 0;
+ if (!live) return null;
+ const real = rows.map((r) => r.message_id).filter((m) => !m.startsWith(SYNTHETIC_ID_PREFIX));
+ // Like the Worker's header cap: the thread's first id and its newest ones.
+ const references =
+ real.length > EMAIL_REPLY_REFERENCE_LIMIT
+ ? [real[0]!, ...real.slice(real.length - (EMAIL_REPLY_REFERENCE_LIMIT - 1))]
+ : real;
+ return {
+ to: opener.sender,
+ alias: opener.alias,
+ inReplyTo: real[real.length - 1] ?? null,
+ references,
+ };
+ }
+
+ /**
+ * The queue consumer's report on an emailed comment: "sent" once the mail
+ * service accepted it, "failed" once delivery gave up. Only a comment still
+ * "sending" moves, so a late or repeated report can't flip a settled one.
+ */
+ markEmailReply(commentId: string, status: "sent" | "failed"): { ok: boolean } {
+ const actor: Actor = { userId: this.systemUserId(), via: "webhook", apiKeyId: null, automationRuleId: null };
+ return this.runTurn(actor, (t) => {
+ const row = this.sql
+ .exec(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE id = ?`, commentId)
+ .toArray()[0];
+ if (!row || row.email_status !== "sending") return { ok: false };
+ this.sql.exec("UPDATE comments SET email_status = ? WHERE id = ?", status, commentId);
+ const comment = toComment({ ...row, email_status: status });
+ t.emit("update", "comment", comment.id, comment as unknown as Record, {
+ taskId: comment.taskId,
+ });
+ t.audit("comment.email_status", comment.id, { taskId: comment.taskId, status });
+ return { ok: true };
});
}
/** The comment write itself, shared with ingestEmail. Callers check access. */
- private applyCommentCreate(t: Turn, input: ParsedCommentInput): Comment {
+ private applyCommentCreate(t: Turn, input: ParsedCommentInput, emailTo: string | null = null): Comment {
const taskRow = this.requireTaskRow(input.taskId);
const comment: Comment = {
id: id("cm_"),
@@ -2492,15 +2625,20 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
// How it arrived: "api"/"mcp" is the author's agent, "ui" the person,
// "webhook" an inbound email. Recorded on the row, not inferred later.
via: t.actor.via,
+ emailTo,
+ emailStatus: emailTo === null ? null : "sending",
};
this.sql.exec(
- "INSERT INTO comments (id, task_id, author_id, body, created_at, via) VALUES (?, ?, ?, ?, ?, ?)",
+ `INSERT INTO comments (id, task_id, author_id, body, created_at, via, email_to, email_status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
comment.id,
comment.taskId,
comment.authorId,
comment.body,
comment.createdAt,
- comment.via
+ comment.via,
+ comment.emailTo,
+ comment.emailStatus
);
t.emit("create", "comment", comment.id, comment as unknown as Record, {
taskId: comment.taskId,
@@ -2519,7 +2657,7 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
return this.runTurn(toActor(actor), (t) => {
const rows = this.sql
.exec(
- "SELECT id, task_id, author_id, body, created_at, via FROM comments WHERE id = ?",
+ `SELECT ${COMMENT_COLUMNS} FROM comments WHERE id = ?`,
commentId
)
.toArray();
@@ -3694,15 +3832,16 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
}
): void {
this.sql.exec(
- `INSERT INTO email_messages (message_id, list_id, alias, task_id, comment_id, sender, received_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO email_messages (message_id, list_id, alias, task_id, comment_id, sender, received_at, authenticated)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
m.messageId,
m.listId,
m.alias,
m.taskId,
m.commentId,
m.sender,
- t.now
+ t.now,
+ m.authenticated ? 1 : 0
);
// The task/comment audit rows name the actor the Worker passed; this one
// records who actually wrote in, and whether Cloudflare vouched for them.
@@ -3850,6 +3989,7 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
createdAt: t.now,
lastUsedAt: null,
revokedAt: null,
+ canEmailRequesters: false,
};
this.sql.exec(
`INSERT INTO api_keys (id, user_id, name, token_hash, created_at, last_used_at, revoked_at)
@@ -3878,6 +4018,31 @@ export class Workspace extends DurableObject implements WorkspaceRpc {
});
}
+ /**
+ * Allow or stop one key emailing a ticket's requester (see requireEmailReply).
+ * Owner or admin, from the app or an api key (there is no switch in the web
+ * app); never over MCP. Audited either way.
+ */
+ setApiKeyEmailRequesters(keyId: string, allowed: boolean, actor: string | Actor): ApiKey {
+ return this.runTurn(toActor(actor), (t) => {
+ if (t.actor.via !== "ui" && t.actor.via !== "api") {
+ throw new Error("Whether a key may email requesters can't be changed over MCP.");
+ }
+ if (!isPrivilegedRole(roleOf(this.sql, t.actor.userId))) {
+ throw new Error("Only a workspace owner or admin can change whether a key may email requesters.");
+ }
+ const row = this.sql
+ .exec(`SELECT ${API_KEY_COLUMNS} FROM api_keys WHERE id = ?`, keyId)
+ .toArray()[0];
+ if (!row) throw new Error(`API key ${keyId} not found.`);
+ if (row.revoked_at !== null) throw new Error(`API key ${keyId} is revoked.`);
+ this.sql.exec("UPDATE api_keys SET can_email_requesters = ? WHERE id = ?", allowed ? 1 : 0, keyId);
+ // Like create and revoke: audited, not board state, so no delta.
+ t.audit("api_key.email_requesters", keyId, { allowed });
+ return toApiKey({ ...row, can_email_requesters: allowed ? 1 : 0 });
+ });
+ }
+
upsertAutomation(input: UpsertAutomationInput, actor: string | Actor): AutomationRule {
return this.runTurn(toActor(actor), (t) => {
const scope = input.scope;
diff --git a/packages/core/src/migrations.test.ts b/packages/core/src/migrations.test.ts
index 399dd30..323a4e5 100644
--- a/packages/core/src/migrations.test.ts
+++ b/packages/core/src/migrations.test.ts
@@ -365,3 +365,66 @@ describe("seedJobs", () => {
expect(recurring.map((r) => r.kind).sort()).toEqual(["backup", "due_date_check", "prune_changes"]);
});
});
+
+describe("core-0014-email-replies", () => {
+ const columns = (sql: SqlStorage, table: string): string[] =>
+ sql
+ .exec<{ name: string }>(`SELECT name FROM pragma_table_info('${table}') ORDER BY cid`)
+ .toArray()
+ .map((r) => r.name);
+
+ it("adds the comment email columns and email_messages.authenticated", () => {
+ const { sql } = freshDb();
+ expect(migrationApplied(sql, "core-0014-email-replies")).toBe(true);
+ expect(columns(sql, "comments")).toEqual(expect.arrayContaining(["email_to", "email_status"]));
+ expect(columns(sql, "email_messages")).toContain("authenticated");
+ expect(columns(sql, "api_keys")).toContain("can_email_requesters");
+ });
+
+ it("backfills authenticated from each message's email.ingest audit row, defaulting to unverified", () => {
+ const db = new DatabaseSync(":memory:");
+ const sql = sqliteStorage(db);
+ const txn = savepointTxn(db);
+ sql.exec("CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)");
+ applyMigrations(sql, MIGRATIONS.filter((m) => m.id !== "core-0014-email-replies"), txn);
+
+ const message = (messageId: string, taskId: string) =>
+ sql.exec(
+ `INSERT INTO email_messages (message_id, list_id, alias, task_id, comment_id, sender, received_at)
+ VALUES (?, 'ls_1', 'support', ?, NULL, 'pat@clinic.com', 0)`,
+ messageId,
+ taskId
+ );
+ const ingestAudit = (messageId: string, taskId: string, authenticated: boolean, listId = "ls_1") =>
+ sql.exec(
+ `INSERT INTO audit (actor, action, entity, diff, at, actor_user_id, api_key_id)
+ VALUES ('{}', 'email.ingest', ?, ?, 0, 'us_1', NULL)`,
+ taskId,
+ JSON.stringify({ listId, alias: "support", sender: "pat@clinic.com", authenticated, messageId, commentId: null })
+ );
+ message("verified@x", "tk_1");
+ ingestAudit("verified@x", "tk_1", true);
+ message("forged@x", "tk_2");
+ ingestAudit("forged@x", "tk_2", false);
+ message("other-list@x", "tk_3");
+ ingestAudit("other-list@x", "tk_3", true, "ls_2");
+ message("no-audit@x", "tk_4");
+ sql.exec(
+ `INSERT INTO comments (id, task_id, author_id, body, created_at) VALUES ('cm_old', 'tk_1', 'us_1', 'hi', 0)`
+ );
+
+ expect(runMigrations(sql, txn)).toEqual(["core-0014-email-replies"]);
+ const flags = Object.fromEntries(
+ sql
+ .exec<{ message_id: string; authenticated: number }>("SELECT message_id, authenticated FROM email_messages")
+ .toArray()
+ .map((r) => [r.message_id, r.authenticated])
+ );
+ expect(flags).toEqual({ "verified@x": 1, "forged@x": 0, "other-list@x": 0, "no-audit@x": 0 });
+ expect(
+ sql.exec<{ email_to: string | null; email_status: string | null }>(
+ "SELECT email_to, email_status FROM comments WHERE id = 'cm_old'"
+ ).one()
+ ).toEqual({ email_to: null, email_status: null });
+ });
+});
diff --git a/packages/core/src/rows.ts b/packages/core/src/rows.ts
index 41ce976..d1886eb 100644
--- a/packages/core/src/rows.ts
+++ b/packages/core/src/rows.ts
@@ -1,4 +1,4 @@
-import { ActorVia, HandoffState } from "@flow/shared";
+import { ActorVia, CommentEmailStatus, HandoffState } from "@flow/shared";
import type {
ApiKey,
Attachment,
@@ -160,6 +160,9 @@ export interface CommentRow {
created_at: number;
/** An ActorVia, or NULL for comments written before core-0013-handoffs. */
via: string | null;
+ /** core-0014-email-replies. Both NULL on an ordinary internal comment. */
+ email_to: string | null;
+ email_status: string | null;
}
export interface AttachmentRow {
@@ -188,6 +191,8 @@ export interface ApiKeyRow {
created_at: number;
last_used_at: number | null;
revoked_at: number | null;
+ /** core-0014-email-replies: 1 when the key may email a ticket's requester. */
+ can_email_requesters: number;
}
/**
@@ -439,6 +444,12 @@ const toActorVia = (raw: string | null): Comment["via"] => {
return parsed.success ? parsed.data : null;
};
+const toEmailStatus = (raw: string | null): Comment["emailStatus"] => {
+ if (raw === null) return null;
+ const parsed = CommentEmailStatus.safeParse(raw);
+ return parsed.success ? parsed.data : null;
+};
+
export const toComment = (r: CommentRow): Comment => ({
id: r.id,
taskId: r.task_id,
@@ -448,6 +459,8 @@ export const toComment = (r: CommentRow): Comment => ({
// Null for comments from before via was recorded, and for anything that is
// not an ActorVia (a hand-edited row): "unknown" beats a wrong answer.
via: toActorVia(r.via ?? null),
+ emailTo: r.email_to ?? null,
+ emailStatus: toEmailStatus(r.email_status ?? null),
});
export const toAttachment = (r: AttachmentRow): Attachment => ({
@@ -480,6 +493,7 @@ export const toApiKey = (r: ApiKeyRow): ApiKey => ({
createdAt: r.created_at,
lastUsedAt: r.last_used_at,
revokedAt: r.revoked_at,
+ canEmailRequesters: r.can_email_requesters === 1,
});
export const toRule = (r: RuleRow): AutomationRule => ({
diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts
index 423f5d9..34aaa22 100644
--- a/packages/core/src/schema.ts
+++ b/packages/core/src/schema.ts
@@ -517,6 +517,33 @@ const HANDOFFS: string[] = [
WHERE handoff_state IS NOT NULL`,
];
+// Email replies (see createComment): a comment the team chose to send back to
+// the person whose email opened the ticket records who it went to and where
+// delivery stands ("sending", then "sent" or "failed" from the queue consumer).
+// NULL in both is an ordinary internal comment, which every earlier one is.
+// `api_keys.can_email_requesters` lets an owner or admin allow one key (say,
+// the one a CLI uses) to send those replies too; every key starts without it.
+// `email_messages.authenticated` keeps whether Cloudflare vouched for each
+// sender. Rows filed before it are backfilled from their email.ingest audit
+// row, and any row that audit can't vouch for reads as unverified (0). Replies
+// go to unverified senders too: the sender is always on the allowlist, and a
+// reply goes to that address, so someone forging it never gets the reply.
+const EMAIL_REPLIES: string[] = [
+ `ALTER TABLE comments ADD COLUMN email_to TEXT`,
+ `ALTER TABLE comments ADD COLUMN email_status TEXT`,
+ `ALTER TABLE api_keys ADD COLUMN can_email_requesters INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE email_messages ADD COLUMN authenticated INTEGER NOT NULL DEFAULT 0`,
+ `UPDATE email_messages SET authenticated = 1
+ WHERE EXISTS (
+ SELECT 1 FROM audit a
+ WHERE a.entity = email_messages.task_id
+ AND a.action = 'email.ingest'
+ AND json_extract(a.diff, '$.messageId') = email_messages.message_id
+ AND json_extract(a.diff, '$.listId') = email_messages.list_id
+ AND json_extract(a.diff, '$.authenticated') = 1
+ )`,
+];
+
/**
* Base schema first, then the automation engine's tables, then notifications.
*
@@ -556,6 +583,7 @@ export const MIGRATIONS: readonly Migration[] = [
{ id: "core-0011-job-status", statements: JOB_STATUS },
{ id: "core-0012-email-intake", statements: EMAIL_INTAKE },
{ id: "core-0013-handoffs", statements: HANDOFFS },
+ { id: "core-0014-email-replies", statements: EMAIL_REPLIES },
];
/**
diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts
index 1eab10e..cfaac2f 100644
--- a/packages/shared/src/api.ts
+++ b/packages/shared/src/api.ts
@@ -147,6 +147,13 @@ export const CreateCommentInput = z.object({
LIMITS.commentBodyMax,
`Comment body must be ${LIMITS.commentBodyMax} characters or fewer.`
),
+ /**
+ * Also email this comment to the person whose email opened the ticket, in
+ * their thread (TaskDetail.emailReplyTo says who that is). Sent by a person
+ * in the web app, or with an api key an owner or admin allowed to
+ * (ApiKey.canEmailRequesters). Never through MCP.
+ */
+ emailToRequester: z.boolean().optional(),
});
export type CreateCommentInput = z.infer;
diff --git a/packages/shared/src/entities.ts b/packages/shared/src/entities.ts
index 9e55959..4d03561 100644
--- a/packages/shared/src/entities.ts
+++ b/packages/shared/src/entities.ts
@@ -197,6 +197,9 @@ export const Subtask = z.object({
});
export type Subtask = z.infer;
+export const CommentEmailStatus = z.enum(["sending", "sent", "failed"]);
+export type CommentEmailStatus = z.infer;
+
export const Comment = z.object({
id: Id,
taskId: Id,
@@ -208,6 +211,14 @@ export const Comment = z.object({
createdAt: Ts,
/** How it was posted; "api"/"mcp" mean the author's agent wrote it. Null before this was recorded. */
via: ActorVia.nullable().default(null),
+ /**
+ * The person who emailed the ticket in, when this comment was also sent to
+ * them as the reply (see CreateCommentInput.emailToRequester). Null for an
+ * ordinary internal comment, which is every comment unless someone opts in.
+ */
+ emailTo: z.string().nullable().default(null),
+ /** Where that email stands: queued, handed to the mail service, or given up on. */
+ emailStatus: CommentEmailStatus.nullable().default(null),
});
export type Comment = z.infer;
@@ -238,6 +249,12 @@ export const ApiKey = z.object({
createdAt: Ts,
lastUsedAt: Ts.nullable().default(null),
revokedAt: Ts.nullable().default(null),
+ /**
+ * May post a comment with emailToRequester (POST /api/tasks/:id/comments).
+ * Off for every key until an owner or admin turns it on with PATCH
+ * /api/api-keys/:id; there is no switch in the app. MCP never gets it.
+ */
+ canEmailRequesters: z.boolean().default(false),
});
export type ApiKey = z.infer;
diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts
index c643cf3..d862bc8 100644
--- a/packages/shared/src/events.ts
+++ b/packages/shared/src/events.ts
@@ -147,5 +147,16 @@ export const TaskDetail = z.object({
subtasks: z.array(Subtask),
comments: z.array(Comment),
attachments: z.array(Attachment),
+ /**
+ * Who a comment posted with emailToRequester would go to: the sender whose
+ * email opened this ticket, verified or not (the sender is always on the
+ * intake's allowlist, and a reply only reaches that address's real owner).
+ * Null when the task didn't come in by email, its intake alias is gone, or
+ * email replies are off. The task's "unverified" tag says whether
+ * Cloudflare vouched for the sender.
+ */
+ emailReplyTo: z.string().nullable().default(null),
+ /** The subject that reply would carry ("Re: "); null whenever emailReplyTo is. */
+ emailReplySubject: z.string().nullable().default(null),
});
export type TaskDetail = z.infer;
From 18effe6d2318a51901804881254f16995b84b8f1 Mon Sep 17 00:00:00 2001
From: Joel Borchardt <121824267+joelborch@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:45:51 -0400
Subject: [PATCH 2/6] Send emailed comments from the side-effects queue
deliverEmailReply sends the comment as the body from EMAIL_FROM /
EMAIL_FROM_NAME, with Reply-To on the intake address and
In-Reply-To/References on the ticket's thread, falling back to no custom
headers if the binding refuses them. Like every other send it only goes
out when EMAIL_DRY_RUN is exactly "false". The consumer then reports
"sent" (guarded, so a failed report never retries into a second email),
and the dead-letter handler reports "failed".
Threading and the header-fallback send move to email-thread.ts, shared
with the intake acknowledgement. PATCH /api/api-keys/:id sets
canEmailRequesters (owner/admin); the comment route returns 403 for the
option from a key nobody allowed; MCP's comment tool doesn't offer it, and
MCP comment views show which comments went to the requester.
wrangler.example.jsonc documents EMAIL_INTAKE_REPLY, off by default.
Co-Authored-By: Claude Opus 5.5
---
apps/api/src/auth.test.ts | 1 +
apps/api/src/do.ts | 8 +
apps/api/src/email-intake.ts | 45 +----
apps/api/src/email-thread.ts | 69 +++++++
apps/api/src/env.ts | 9 +-
apps/api/src/mcp/schemas-out.ts | 3 +
apps/api/src/mcp/schemas.ts | 3 +-
apps/api/src/mcp/server.test.ts | 1 +
apps/api/src/mcp/views.ts | 3 +
apps/api/src/routes/api-keys.ts | 23 +++
apps/api/src/routes/comments.test.ts | 141 ++++++++++++++
apps/api/src/routes/tasks.ts | 10 +-
apps/api/src/side-effects/email-reply.test.ts | 183 ++++++++++++++++++
apps/api/src/side-effects/index.ts | 131 ++++++++++++-
apps/api/wrangler.example.jsonc | 7 +-
15 files changed, 593 insertions(+), 44 deletions(-)
create mode 100644 apps/api/src/email-thread.ts
create mode 100644 apps/api/src/routes/comments.test.ts
create mode 100644 apps/api/src/side-effects/email-reply.test.ts
diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts
index 9f4b62f..362b65d 100644
--- a/apps/api/src/auth.test.ts
+++ b/apps/api/src/auth.test.ts
@@ -48,6 +48,7 @@ const key: ApiKey = {
createdAt: 1_700_000_000_000,
lastUsedAt: null,
revokedAt: null,
+ canEmailRequesters: false,
};
const env = {
diff --git a/apps/api/src/do.ts b/apps/api/src/do.ts
index 4bfd450..00ce6b8 100644
--- a/apps/api/src/do.ts
+++ b/apps/api/src/do.ts
@@ -111,6 +111,8 @@ export interface WorkspaceApi extends WorkspaceRpc {
actor: string | Actor
): Promise;
revokeApiKey(keyId: string, actor: string | Actor): Promise;
+ /** Owner/admin, from the web app only: allow or stop a key emailing ticket requesters. */
+ setApiKeyEmailRequesters(keyId: string, allowed: boolean, actor: string | Actor): Promise;
// --- tasks ----------------------------------------------------------------
createTask(input: CreateTaskInput, actor: string | Actor): Promise;
@@ -287,6 +289,12 @@ export interface WorkspaceApi extends WorkspaceRpc {
kind: "webhook" | "email";
detail: string;
}): Promise;
+ /**
+ * The side-effects consumer's report on an emailed comment: "sent" once the
+ * mail service accepted it, "failed" once delivery gave up. Moves only a
+ * comment still "sending"; `ok` is false when there was nothing to move.
+ */
+ markEmailReply(commentId: string, status: "sent" | "failed"): Promise<{ ok: boolean }>;
// --- audit ----------------------------------------------------------------
/**
diff --git a/apps/api/src/email-intake.ts b/apps/api/src/email-intake.ts
index a67e459..7caf242 100644
--- a/apps/api/src/email-intake.ts
+++ b/apps/api/src/email-intake.ts
@@ -10,12 +10,14 @@ import {
type IngestEmailResult,
} from "@flow/core";
import { id, ID_PREFIX } from "@flow/core/id";
+import { SYNTHETIC_ID_PREFIX } from "@flow/core/email-intake";
import { LIMITS, type Actor } from "@flow/shared";
import { findOwnerActor } from "./auth.js";
import { workspace } from "./do.js";
import type { Env } from "./env.js";
import { contentDisposition, mimeEssence, sanitizeFilename } from "./routes/attachments.js";
import { emailBrandName, emailFrom, renderEmailHtml } from "./side-effects/index.js";
+import { HEADER_VALUE_MAX_BYTES, errorCode, sendWithHeaders, threadingHeaders } from "./email-thread.js";
/**
* Email intake: the Worker half.
@@ -55,14 +57,13 @@ export const PERMANENT_REJECT = "This message could not be filed. Please contact
const UNREADABLE_REJECT = "This message could not be read. Please send it again as a regular email.";
/** Prefix of the dedupe key minted for a message that arrives without a Message-ID. */
-export const SYNTHETIC_ID_PREFIX = "sha256:";
+export { SYNTHETIC_ID_PREFIX };
/** Fixed, so nothing the sender wrote is ever reflected back out in our name. */
export const ACK_SUBJECT = "We received your request";
-/** Email Service caps each custom header value at 2,048 bytes. */
-export const HEADER_VALUE_MAX_BYTES = 2048;
+export { HEADER_VALUE_MAX_BYTES, threadingHeaders };
/** Text and HTML parts are cut to this many characters before any regex or normalization runs. */
export const BODY_SOURCE_MAX = 2 * LIMITS.descriptionMax;
@@ -899,28 +900,6 @@ export function acknowledgementMarkdown(): string {
].join("\n");
}
-function utf8Length(text: string): number {
- return new TextEncoder().encode(text).byteLength;
-}
-
-/**
- * In-Reply-To and References for the acknowledgement, so the client's reply
- * carries the original Message-ID in its References and threads back onto the
- * ticket. When the chain outgrows the header value cap, ids are dropped from
- * the middle: RFC 5322 §3.6.4 keeps the thread's first id and its newest ones.
- * Null when the message had no real Message-ID to point at.
- */
-export function threadingHeaders(messageId: string, parentIds: readonly string[]): Record | null {
- if (messageId.startsWith(SYNTHETIC_ID_PREFIX) || /[\r\n]/.test(messageId)) return null;
- const inReplyTo = `<${messageId}>`;
- if (utf8Length(inReplyTo) > HEADER_VALUE_MAX_BYTES) return null;
- const ids = [...new Set([...parentIds, messageId])];
- const format = () => ids.map((value) => `<${value}>`).join(" ");
- while (ids.length > 2 && utf8Length(format()) > HEADER_VALUE_MAX_BYTES) ids.splice(1, 1);
- const references = utf8Length(format()) > HEADER_VALUE_MAX_BYTES ? inReplyTo : format();
- return { "In-Reply-To": inReplyTo, References: references };
-}
-
export interface Acknowledgement {
/** The send without custom headers; the fallback when they are refused. */
base: EmailMessageBuilder;
@@ -964,12 +943,6 @@ export function buildAcknowledgement(input: {
};
}
-function errorCode(err: unknown): string {
- const code = (err as { code?: unknown } | null)?.code;
- if (typeof code === "string") return code;
- return /\bE_HEADER\w*/.exec(errorMessage(err))?.[0] ?? "";
-}
-
/** "Name " for the dry-run log, whichever form the builder holds. */
function senderLabel(from: EmailMessageBuilder["from"]): string {
return typeof from === "string" ? from : `${from.name ?? ""} <${from.email}>`.trim();
@@ -995,20 +968,16 @@ export async function sendAcknowledgement(env: Env, ack: Acknowledgement, taskId
if (!binding) {
throw new Error('EMAIL_DRY_RUN is off but the SEND_EMAIL binding is missing from wrangler.jsonc');
}
- try {
- await binding.send({ ...ack.base, headers: ack.headers });
- } catch (err) {
- if (!errorCode(err).startsWith("E_HEADER")) throw err;
+ await sendWithHeaders(binding, ack.base, ack.headers, (code) => {
console.warn(
JSON.stringify({
level: "warn",
msg: "email intake ack: threading headers were refused; resending without them",
taskId,
- code: errorCode(err),
+ code,
})
);
- await binding.send(ack.base);
- }
+ });
}
// --- the handler --------------------------------------------------------------
diff --git a/apps/api/src/email-thread.ts b/apps/api/src/email-thread.ts
new file mode 100644
index 0000000..cb30475
--- /dev/null
+++ b/apps/api/src/email-thread.ts
@@ -0,0 +1,69 @@
+///
+// Threading and sending for mail Flow writes back to the person who emailed a
+// ticket in: the intake acknowledgement (./email-intake.ts) and the comments a
+// person chooses to email back (./side-effects). Kept apart from both so
+// neither has to import the other.
+
+import { SYNTHETIC_ID_PREFIX } from "@flow/core/email-intake";
+
+/** Email Service caps each custom header value at 2,048 bytes. */
+export const HEADER_VALUE_MAX_BYTES = 2048;
+
+function utf8Length(text: string): number {
+ return new TextEncoder().encode(text).byteLength;
+}
+
+/** A message id that can go inside angle brackets in a header: no whitespace, brackets or synthetic key. */
+function headerSafeId(value: string): boolean {
+ return value !== "" && !value.startsWith(SYNTHETIC_ID_PREFIX) && !/[\s<>]/.test(value);
+}
+
+/**
+ * In-Reply-To and References, so the requester's reply carries the ticket's
+ * Message-IDs in its References and threads back onto it. When the chain
+ * outgrows the header value cap, ids are dropped from the middle: RFC 5322
+ * §3.6.4 keeps the thread's first id and its newest ones. Null when there is
+ * no real Message-ID to point at.
+ */
+export function threadingHeaders(messageId: string, parentIds: readonly string[]): Record | null {
+ if (!headerSafeId(messageId)) return null;
+ const inReplyTo = `<${messageId}>`;
+ if (utf8Length(inReplyTo) > HEADER_VALUE_MAX_BYTES) return null;
+ const ids = [...new Set([...parentIds.filter(headerSafeId), messageId])];
+ const format = () => ids.map((value) => `<${value}>`).join(" ");
+ while (ids.length > 2 && utf8Length(format()) > HEADER_VALUE_MAX_BYTES) ids.splice(1, 1);
+ const references = utf8Length(format()) > HEADER_VALUE_MAX_BYTES ? inReplyTo : format();
+ return { "In-Reply-To": inReplyTo, References: references };
+}
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+/** The Email Service error code (E_HEADER_…, E_RATE_LIMIT…), or "" when there isn't one. */
+export function errorCode(err: unknown): string {
+ const code = (err as { code?: unknown } | null)?.code;
+ if (typeof code === "string") return code;
+ return /\bE_HEADER\w*/.exec(errorMessage(err))?.[0] ?? "";
+}
+
+/**
+ * Send with custom headers, and once more without them if the binding refuses
+ * a header: mail that lands outside the thread beats mail that never lands.
+ * `onHeadersRefused` gets the refusal's code so the caller can log it.
+ */
+export async function sendWithHeaders(
+ binding: SendEmail,
+ base: EmailMessageBuilder,
+ headers: Record,
+ onHeadersRefused: (code: string) => void
+): Promise {
+ try {
+ await binding.send({ ...base, headers });
+ } catch (err) {
+ const code = errorCode(err);
+ if (!code.startsWith("E_HEADER")) throw err;
+ onHeadersRefused(code);
+ await binding.send(base);
+ }
+}
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index 35fc899..3fd7b3d 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -40,7 +40,14 @@ export type Env = CoreEnv & {
* writes, because an ack only goes to senders that header vouches for.
*/
EMAIL_INTAKE_ACK?: string;
- /** Email Service binding (`send_email` in wrangler.jsonc): notifications, automations, intake acks. */
+ /**
+ * "true" lets a person in the web app (or an api key allowed to) email a
+ * comment back to the requester of a ticket that came in by email. Read by
+ * the DO, which refuses replies while it's off; the panel then hides the
+ * option.
+ */
+ EMAIL_INTAKE_REPLY?: string;
+ /** Email Service binding (`send_email` in wrangler.jsonc): notifications, automations, intake acks and replies. */
SEND_EMAIL?: SendEmail;
/**
* Local-dev escape hatch. Auth resolves to the owner user when this is
diff --git a/apps/api/src/mcp/schemas-out.ts b/apps/api/src/mcp/schemas-out.ts
index f2fa7c5..a75dd3a 100644
--- a/apps/api/src/mcp/schemas-out.ts
+++ b/apps/api/src/mcp/schemas-out.ts
@@ -106,6 +106,9 @@ export const CommentView = z.object({
createdAt: z.number(),
/** True when the author's agent wrote it (posted with a key); absent otherwise. */
agent: z.boolean().optional(),
+ /** Set when this comment was also emailed to the ticket's requester: who, and where delivery stands. */
+ emailedTo: z.string().optional(),
+ emailStatus: z.enum(["sending", "sent", "failed"]).optional(),
});
export type CommentView = z.infer;
diff --git a/apps/api/src/mcp/schemas.ts b/apps/api/src/mcp/schemas.ts
index 04a641f..8163d86 100644
--- a/apps/api/src/mcp/schemas.ts
+++ b/apps/api/src/mcp/schemas.ts
@@ -46,7 +46,8 @@ export const UpdateTaskArgs = UpdateTaskInput;
export const MoveTaskArgs = MoveTaskInput;
export const BulkUpdateTasksArgs = BulkUpdateInput;
export const ToggleSubtaskArgs = ToggleSubtaskInput;
-export const CommentOnTaskArgs = CreateCommentInput;
+/** No emailToRequester: emailing a client is for a person in the app, never an agent. */
+export const CommentOnTaskArgs = CreateCommentInput.omit({ emailToRequester: true });
export const UpsertAutomationArgs = UpsertAutomationInput;
export const RequestHandoffArgs = RequestHandoffInput;
export const ListHandoffsArgs = ListHandoffsInput.extend({ format: Format });
diff --git a/apps/api/src/mcp/server.test.ts b/apps/api/src/mcp/server.test.ts
index ec61b92..5e07605 100644
--- a/apps/api/src/mcp/server.test.ts
+++ b/apps/api/src/mcp/server.test.ts
@@ -122,6 +122,7 @@ const auth: AuthContext = {
createdAt: 0,
lastUsedAt: null,
revokedAt: null,
+ canEmailRequesters: false,
},
actor: { userId: "us_alice", via: "api", apiKeyId: "ak_1", automationRuleId: null },
};
diff --git a/apps/api/src/mcp/views.ts b/apps/api/src/mcp/views.ts
index 55d473f..ebc9ff6 100644
--- a/apps/api/src/mcp/views.ts
+++ b/apps/api/src/mcp/views.ts
@@ -154,6 +154,9 @@ export function commentView(comment: Comment, names: NameIndex): CommentView {
// So an agent reading a handoff thread can tell the teammate from the
// teammate's agent. Older comments have no `via` and read as human.
...(comment.via != null && AGENT_VIA.has(comment.via) ? { agent: true } : {}),
+ // So an agent can tell what the client was actually sent from internal notes.
+ ...(comment.emailTo != null ? { emailedTo: comment.emailTo } : {}),
+ ...(comment.emailStatus != null ? { emailStatus: comment.emailStatus } : {}),
};
}
diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts
index 4d4084b..df13f79 100644
--- a/apps/api/src/routes/api-keys.ts
+++ b/apps/api/src/routes/api-keys.ts
@@ -100,6 +100,29 @@ apiKeyRoutes.post("/api-keys", async (c) => {
);
});
+const UpdateApiKeyBody = z.object({ canEmailRequesters: z.boolean() });
+
+/**
+ * Allow or stop a key emailing a ticket's requester (POST
+ * /api/tasks/:id/comments with emailToRequester). Owner/admin only, from the
+ * app or an api key; MCP is refused in the DO. There is no switch in the web
+ * app, so this route is how it's set.
+ */
+apiKeyRoutes.patch("/api-keys/:apiKeyId", async (c) => {
+ const auth = requireAuth(c);
+ const apiKeyId = parseOrThrow(Id, c.req.param("apiKeyId"), "apiKeyId");
+ const input = parseOrThrow(UpdateApiKeyBody, await readJson(c));
+ if (!managesAllKeys(auth.user.role)) {
+ throw forbidden("only a workspace owner or admin can change whether a key may email requesters");
+ }
+ const { tokenHash, ...safe } = await workspace(c.env).setApiKeyEmailRequesters(
+ apiKeyId,
+ input.canEmailRequesters,
+ auth.actor
+ );
+ return c.json({ apiKey: { ...safe, tokenFingerprint: tokenHash.slice(0, 8) } });
+});
+
/** Revoke. Idempotent from the caller's point of view. */
apiKeyRoutes.delete("/api-keys/:apiKeyId", async (c) => {
const auth = requireAuth(c);
diff --git a/apps/api/src/routes/comments.test.ts b/apps/api/src/routes/comments.test.ts
new file mode 100644
index 0000000..fa0ec15
--- /dev/null
+++ b/apps/api/src/routes/comments.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it, vi } from "vitest";
+import { Hono } from "hono";
+import type { Actor, ApiKey, User } from "@flow/shared";
+import type { AppEnv, AuthContext, Env } from "../env.js";
+import { onError } from "../errors.js";
+import { CommentOnTaskArgs } from "../mcp/schemas.js";
+import { apiKeyRoutes } from "./api-keys.js";
+import { taskRoutes } from "./tasks.js";
+
+// POST /api/tasks/:taskId/comments with emailToRequester, and PATCH
+// /api/api-keys/:id, which allows a key to send those. Whether the ticket can
+// be answered is the DO's rule and is tested there; under test here is that a
+// key nobody allowed gets a plain 403 before the DO is called, that only an
+// owner or admin can allow one, and that MCP's comment tool never offers the
+// option at all.
+
+const user: User = {
+ id: "us_owner",
+ email: "owner@example.com",
+ name: "Owner",
+ role: "owner",
+ deactivated: false,
+ createdAt: 1_700_000_000_000,
+};
+
+function apiKey(canEmailRequesters: boolean): ApiKey {
+ return {
+ id: "ak_1",
+ userId: "us_owner",
+ name: "cli",
+ tokenHash: "0".repeat(64),
+ createdAt: 0,
+ lastUsedAt: null,
+ revokedAt: null,
+ canEmailRequesters,
+ };
+}
+
+function appWith(
+ actor: Actor,
+ key: ApiKey | null = null,
+ stub: Record = {},
+ who: User = user
+) {
+ const createComment = vi.fn().mockResolvedValue({ id: "cm_1" });
+ const setApiKeyEmailRequesters = vi.fn().mockResolvedValue({ ...apiKey(true) });
+ const auth: AuthContext = { user: who, apiKey: key, actor };
+ const app = new Hono();
+ app.onError(onError);
+ app.use("*", async (c, next) => {
+ c.set("auth", auth);
+ return next();
+ });
+ app.route("/api", taskRoutes);
+ app.route("/api", apiKeyRoutes);
+ const env = {
+ WORKSPACE: { idFromName: () => ({}), get: () => ({ createComment, setApiKeyEmailRequesters, ...stub }) },
+ } as unknown as Env;
+ const send = (method: string, path: string, body: unknown) =>
+ app.request(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, env);
+ return {
+ createComment,
+ setApiKeyEmailRequesters,
+ post: (body: unknown) => send("POST", "/api/tasks/tk_1/comments", body),
+ patchKey: (body: unknown) => send("PATCH", "/api/api-keys/ak_1", body),
+ };
+}
+
+const PERSON: Actor = { userId: "us_owner", via: "ui", apiKeyId: null, automationRuleId: null };
+const API_KEY: Actor = { userId: "us_owner", via: "api", apiKeyId: "ak_1", automationRuleId: null };
+
+describe("POST /api/tasks/:taskId/comments with emailToRequester", () => {
+ it("passes a person's reply through to the DO", async () => {
+ const { post, createComment } = appWith(PERSON);
+ const res = await post({ body: "Done", emailToRequester: true });
+ expect(res.status).toBe(201);
+ expect(createComment).toHaveBeenCalledWith(
+ { taskId: "tk_1", body: "Done", emailToRequester: true },
+ PERSON
+ );
+ });
+
+ it("passes a reply from a key an owner allowed to email requesters", async () => {
+ const { post, createComment } = appWith(API_KEY, apiKey(true));
+ expect((await post({ body: "Done", emailToRequester: true })).status).toBe(201);
+ expect(createComment).toHaveBeenCalledWith({ taskId: "tk_1", body: "Done", emailToRequester: true }, API_KEY);
+ });
+
+ it("turns away a key nobody allowed, with a 403 before the DO is called", async () => {
+ const { post, createComment } = appWith(API_KEY, apiKey(false));
+ const res = await post({ body: "Done", emailToRequester: true });
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual({ error: expect.stringMatching(/allowed to email requesters/) });
+ expect(createComment).not.toHaveBeenCalled();
+ });
+
+ it("still lets an api key post an ordinary comment", async () => {
+ const { post, createComment } = appWith(API_KEY);
+ expect((await post({ body: "Note" })).status).toBe(201);
+ expect(createComment).toHaveBeenCalledWith({ taskId: "tk_1", body: "Note" }, API_KEY);
+ });
+});
+
+describe("flow_comment_on_task", () => {
+ it("has no email option, and strips one an agent sends anyway", () => {
+ expect(Object.keys(CommentOnTaskArgs.shape)).not.toContain("emailToRequester");
+ expect(CommentOnTaskArgs.parse({ taskId: "tk_1", body: "Done", emailToRequester: true })).toEqual({
+ taskId: "tk_1",
+ body: "Done",
+ });
+ });
+});
+
+describe("PATCH /api/api-keys/:apiKeyId", () => {
+ it("lets an owner in the app allow a key, returning it without the hash", async () => {
+ const { patchKey, setApiKeyEmailRequesters } = appWith(PERSON);
+ const res = await patchKey({ canEmailRequesters: true });
+ expect(res.status).toBe(200);
+ expect(setApiKeyEmailRequesters).toHaveBeenCalledWith("ak_1", true, PERSON);
+ const body = (await res.json()) as { apiKey: Record };
+ expect(body.apiKey).toMatchObject({ id: "ak_1", canEmailRequesters: true, tokenFingerprint: "00000000" });
+ expect(body.apiKey).not.toHaveProperty("tokenHash");
+ });
+
+ it("works from an owner's api key, since the CLI is where this is set", async () => {
+ const fromKey = appWith(API_KEY, apiKey(false));
+ expect((await fromKey.patchKey({ canEmailRequesters: true })).status).toBe(200);
+ expect(fromKey.setApiKeyEmailRequesters).toHaveBeenCalledWith("ak_1", true, API_KEY);
+ });
+
+ it("is 403 for a member", async () => {
+ const member = appWith(PERSON, null, {}, { ...user, id: "us_member", role: "member" });
+ expect((await member.patchKey({ canEmailRequesters: true })).status).toBe(403);
+ expect(member.setApiKeyEmailRequesters).not.toHaveBeenCalled();
+ });
+
+ it("is 422 without a boolean", async () => {
+ const { patchKey } = appWith(PERSON);
+ expect((await patchKey({ canEmailRequesters: "yes" })).status).toBe(422);
+ });
+});
diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts
index b57c393..f91e55f 100644
--- a/apps/api/src/routes/tasks.ts
+++ b/apps/api/src/routes/tasks.ts
@@ -16,7 +16,7 @@ import {
import { requireAuth } from "../auth.js";
import { workspace } from "../do.js";
import type { AppEnv } from "../env.js";
-import { badRequest, notFound, parseOrThrow, readJson } from "../errors.js";
+import { badRequest, forbidden, notFound, parseOrThrow, readJson } from "../errors.js";
export const taskRoutes = new Hono();
@@ -293,6 +293,14 @@ taskRoutes.post("/tasks/:taskId/comments", async (c) => {
...(typeof body === "object" && body !== null ? body : {}),
taskId,
});
+ // The DO refuses this too; saying it here makes the status a plain 403.
+ const mayEmail =
+ auth.actor.via === "ui" || (auth.actor.via === "api" && auth.apiKey?.canEmailRequesters === true);
+ if (input.emailToRequester === true && !mayEmail) {
+ throw forbidden(
+ "Email replies need an API key allowed to email requesters (PATCH /api/api-keys/:id), and never go over MCP."
+ );
+ }
return c.json(await workspace(c.env).createComment(input, auth.actor), 201);
});
diff --git a/apps/api/src/side-effects/email-reply.test.ts b/apps/api/src/side-effects/email-reply.test.ts
new file mode 100644
index 0000000..dc0e1e1
--- /dev/null
+++ b/apps/api/src/side-effects/email-reply.test.ts
@@ -0,0 +1,183 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { SideEffectPayload } from "@flow/core/automation";
+import {
+ DEFAULT_EMAIL_FROM,
+ deliverEmailReply,
+ handleDeadLetterBatch,
+ handleSideEffectBatch,
+ parsePayload,
+} from "./index.js";
+import type { Env } from "../env.js";
+
+// ---------------------------------------------------------------------------
+// Emailed comments ("email-reply" queue messages): parsing, the send itself
+// (sender, Reply-To back onto the intake, threading headers and their
+// fallback), and the "sent"/"failed" report back to the card, which must never
+// turn into a retry that would email the requester twice.
+// ---------------------------------------------------------------------------
+
+type Reply = Extract;
+
+function reply(over: Partial = {}): Reply {
+ return {
+ kind: "email-reply",
+ to: "pat@clinic.com",
+ alias: "support",
+ subject: "Re: Printer jammed",
+ body: "Done, here it is: https://example.com/page",
+ inReplyTo: "second@clinic.com",
+ references: ["first@clinic.com", "second@clinic.com"],
+ taskId: "tk_1",
+ commentId: "cm_1",
+ ...over,
+ };
+}
+
+function liveEnv(send = vi.fn().mockResolvedValue(undefined), over: Record = {}) {
+ return {
+ send,
+ env: {
+ EMAIL_DRY_RUN: "false",
+ APP_HOSTNAME: "flow.example.com",
+ EMAIL_INTAKE_DOMAIN: "help.example.com",
+ SEND_EMAIL: { send },
+ ...over,
+ },
+ };
+}
+
+/** env.WORKSPACE stands in for the DO namespace; its stub carries markEmailReply. */
+function queueEnv(send = vi.fn().mockResolvedValue(undefined), markEmailReply = vi.fn().mockResolvedValue({ ok: true })) {
+ const stub = { markEmailReply, recordDeliveryFailure: vi.fn().mockResolvedValue({ ok: true }) };
+ const env = {
+ ...liveEnv(send).env,
+ WORKSPACE: { idFromName: () => "id_main", get: () => stub },
+ } as unknown as Env;
+ return { env, send, markEmailReply };
+}
+
+function fakeMessage(body: unknown, attempts = 1) {
+ return { id: "msg_1", body, attempts, ack: vi.fn(), retry: vi.fn() };
+}
+
+function fakeBatch(messages: ReturnType[], queue = "flow-side-effects") {
+ return { queue, messages } as unknown as MessageBatch;
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("parsePayload: email-reply", () => {
+ it("round-trips a queued reply", () => {
+ expect(parsePayload(JSON.parse(JSON.stringify(reply())))).toEqual(reply());
+ });
+
+ it("drops a message missing its recipient, alias, comment or task", () => {
+ for (const missing of ["to", "alias", "commentId", "taskId"] as const) {
+ const body: Record = { ...reply() };
+ delete body[missing];
+ expect(parsePayload(body)).toBeNull();
+ }
+ });
+});
+
+describe("deliverEmailReply", () => {
+ it("sends the comment as the body, from the configured sender, with Reply-To on the intake and the thread's headers", async () => {
+ const { env, send } = liveEnv();
+ await deliverEmailReply(reply(), env);
+ expect(send).toHaveBeenCalledTimes(1);
+ const message = send.mock.calls[0]![0];
+ expect(message).toMatchObject({
+ from: DEFAULT_EMAIL_FROM,
+ to: "pat@clinic.com",
+ replyTo: "support@help.example.com",
+ subject: "Re: Printer jammed",
+ text: "Done, here it is: https://example.com/page",
+ headers: {
+ "In-Reply-To": "",
+ References: " ",
+ },
+ });
+ expect(message.html).toContain("https://example.com/page");
+ // External shell: nothing links back into Flow.
+ expect(message.html).not.toContain("flow.example.com");
+ });
+
+ it("resends without the threading headers when the binding refuses them", async () => {
+ const send = vi
+ .fn()
+ .mockRejectedValueOnce(Object.assign(new Error("bad header"), { code: "E_HEADER_NOT_ALLOWED" }))
+ .mockResolvedValue(undefined);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const { env } = liveEnv(send);
+ await deliverEmailReply(reply(), env);
+ expect(send).toHaveBeenCalledTimes(2);
+ expect(send.mock.calls[1]![0]).not.toHaveProperty("headers");
+ expect(warn).toHaveBeenCalled();
+ });
+
+ it("sends without headers when the ticket had no real Message-ID", async () => {
+ const { env, send } = liveEnv();
+ await deliverEmailReply(reply({ inReplyTo: null, references: [] }), env);
+ expect(send.mock.calls[0]![0]).not.toHaveProperty("headers");
+ });
+
+ it("throws rather than send a reply with nowhere to come back to", async () => {
+ const { env, send } = liveEnv(undefined, { EMAIL_INTAKE_DOMAIN: undefined });
+ await expect(deliverEmailReply(reply(), env)).rejects.toThrow(/EMAIL_INTAKE_DOMAIN/);
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("only logs in dry run", async () => {
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const { env, send } = liveEnv(undefined, { EMAIL_DRY_RUN: "true" });
+ await deliverEmailReply(reply(), env);
+ expect(send).not.toHaveBeenCalled();
+ expect(log.mock.calls[0]?.[0]).toContain("would send email reply");
+ });
+});
+
+describe("handleSideEffectBatch: email-reply", () => {
+ it("marks the comment sent once the mail service took it", async () => {
+ const { env, send, markEmailReply } = queueEnv();
+ const msg = fakeMessage(reply());
+ await handleSideEffectBatch(fakeBatch([msg]), env);
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(markEmailReply).toHaveBeenCalledWith("cm_1", "sent");
+ expect(msg.ack).toHaveBeenCalled();
+ expect(msg.retry).not.toHaveBeenCalled();
+ });
+
+ it("acks even when the sent report fails, so the requester is never emailed twice", async () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { env, send } = queueEnv(undefined, vi.fn().mockRejectedValue(new Error("DO unavailable")));
+ const msg = fakeMessage(reply());
+ await handleSideEffectBatch(fakeBatch([msg]), env);
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(msg.ack).toHaveBeenCalled();
+ expect(msg.retry).not.toHaveBeenCalled();
+ expect(error).toHaveBeenCalled();
+ });
+
+ it("retries a failed send without reporting anything yet", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { env, markEmailReply } = queueEnv(vi.fn().mockRejectedValue(new Error("E_RATE_LIMIT")));
+ const msg = fakeMessage(reply(), 2);
+ await handleSideEffectBatch(fakeBatch([msg]), env);
+ expect(msg.retry).toHaveBeenCalledWith({ delaySeconds: 60 });
+ expect(msg.ack).not.toHaveBeenCalled();
+ expect(markEmailReply).not.toHaveBeenCalled();
+ });
+});
+
+describe("handleDeadLetterBatch: email-reply", () => {
+ it("marks the comment failed once retries are exhausted", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { env, markEmailReply } = queueEnv();
+ const msg = fakeMessage(reply(), 6);
+ await handleDeadLetterBatch(fakeBatch([msg], "flow-dlq"), env);
+ expect(markEmailReply).toHaveBeenCalledWith("cm_1", "failed");
+ expect(msg.ack).toHaveBeenCalled();
+ });
+});
diff --git a/apps/api/src/side-effects/index.ts b/apps/api/src/side-effects/index.ts
index 4addfd2..b2af4bc 100644
--- a/apps/api/src/side-effects/index.ts
+++ b/apps/api/src/side-effects/index.ts
@@ -34,6 +34,7 @@ import {
type GleapScreenshotJob,
} from "../gleap-screenshot.js";
import { workspace } from "../do.js";
+import { sendWithHeaders, threadingHeaders } from "../email-thread.js";
import type { Env } from "../env.js";
export const DEFAULT_EMAIL_FROM = { email: "flow@mail.example.com", name: "Flow" };
@@ -50,6 +51,8 @@ export interface SideEffectEnv {
EMAIL_FROM?: string;
EMAIL_FROM_NAME?: string;
EMAIL_BRAND_NAME?: string;
+ /** Reply-To for an emailed comment: `@EMAIL_INTAKE_DOMAIN`, back onto the ticket. */
+ EMAIL_INTAKE_DOMAIN?: string;
SEND_EMAIL?: SendEmail;
}
@@ -93,12 +96,21 @@ export async function handleSideEffectBatch(batch: MessageBatch, env: Env): Prom
);
}
} else if (payload.kind === "webhook") await deliverWebhook(payload);
- else await deliverEmail(payload, env);
+ else if (payload.kind === "email-reply") {
+ await deliverEmailReply(payload, env);
+ // After the send, guarded: a retry from here would email them twice.
+ await reportEmailReply(env, payload.commentId, "sent");
+ } else await deliverEmail(payload, env);
msg.ack();
} catch (err) {
// Per-message retry rather than throwing the whole batch, so one dead
// endpoint doesn't re-deliver its neighbours. max_retries 5 -> flow-dlq.
- const rule = payload.kind === "gleap-screenshot" ? "" : ` rule=${payload.ruleId}`;
+ const rule =
+ payload.kind === "gleap-screenshot"
+ ? ""
+ : payload.kind === "email-reply"
+ ? ` comment=${payload.commentId}`
+ : ` rule=${payload.ruleId}`;
console.error(
`side-effects: ${payload.kind} failed (attempt ${msg.attempts})${rule} task=${payload.taskId}`,
err instanceof Error ? err.message : err
@@ -142,16 +154,19 @@ export async function handleDeadLetterBatch(batch: MessageBatch, env: Env): Prom
payload !== null && (payload.kind === "webhook" || payload.kind === "email")
? payload
: null;
+ const reply = payload !== null && payload.kind === "email-reply" ? payload : null;
console.error(
JSON.stringify({
level: "error",
msg: "flow.dlq",
kind: payload?.kind ?? "unknown",
ruleId: deliverable?.ruleId,
+ commentId: reply?.commentId,
taskId: payload?.taskId,
attempts: msg.attempts,
})
);
+ if (reply !== null) await reportEmailReply(env, reply.commentId, "failed");
if (deliverable !== null) {
await recordPermanentFailure(
env,
@@ -184,6 +199,23 @@ async function recordPermanentFailure(
}
}
+/** Guarded like recordPermanentFailure: the card's email status is a report, never a reason to retry. */
+async function reportEmailReply(env: Env, commentId: string, status: "sent" | "failed"): Promise {
+ try {
+ await workspace(env).markEmailReply(commentId, status);
+ } catch (err) {
+ console.error(
+ JSON.stringify({
+ level: "error",
+ msg: "side-effects: markEmailReply failed",
+ commentId,
+ status,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ );
+ }
+}
+
/**
* Exponential backoff for webhook and email retries: 30s * 2^(attempts-1),
* capped at 1 hour so a long-dead endpoint doesn't push the eventual
@@ -215,6 +247,26 @@ export function parsePayload(body: unknown): SideEffectPayload | GleapScreenshot
return { kind: "webhook", url, secret, body: envelope as WebhookPayload, ruleId, taskId };
}
+ if (b["kind"] === "email-reply") {
+ const to = b["to"];
+ const alias = b["alias"];
+ const commentId = b["commentId"];
+ if (typeof to !== "string" || to === "" || typeof alias !== "string" || alias === "") return null;
+ if (typeof commentId !== "string" || commentId === "" || taskId === "") return null;
+ const rawRefs = b["references"];
+ return {
+ kind: "email-reply",
+ to,
+ alias,
+ subject: typeof b["subject"] === "string" ? b["subject"] : "",
+ body: typeof b["body"] === "string" ? b["body"] : "",
+ inReplyTo: typeof b["inReplyTo"] === "string" ? b["inReplyTo"] : null,
+ references: Array.isArray(rawRefs) ? rawRefs.filter((r): r is string => typeof r === "string") : [],
+ taskId,
+ commentId,
+ };
+ }
+
if (b["kind"] === "email") {
const raw = b["to"];
const to = Array.isArray(raw) ? raw.filter((t): t is string => typeof t === "string") : [];
@@ -375,6 +427,81 @@ export async function deliverEmail(
});
}
+// --- emailed comments -------------------------------------------------------
+
+/**
+ * A comment a person chose to email back to the ticket's requester. It goes
+ * out like the intake acknowledgement (same sender, Reply-To the intake
+ * address so their answer lands back on the ticket, in their thread), with
+ * the comment's own words as the body, so the card shows exactly what they got.
+ */
+export async function deliverEmailReply(
+ payload: Extract,
+ env: SideEffectEnv
+): Promise {
+ const domain = env.EMAIL_INTAKE_DOMAIN;
+ if (!domain) throw new Error("EMAIL_INTAKE_DOMAIN is not set, so a reply would have no address to come back to");
+ const replyTo = `${payload.alias}@${domain}`;
+ // Same sender as the intake acknowledgement: EMAIL_FROM / EMAIL_FROM_NAME.
+ const from = emailFrom(env);
+ const html = renderEmailHtml(payload.body, {
+ // External shell: clients can't sign in to Flow, so nothing links to it.
+ audience: "external",
+ appHostname: env.APP_HOSTNAME ?? "flow.example.com",
+ brandName: emailBrandName(env),
+ });
+ const headers =
+ payload.inReplyTo === null
+ ? null
+ : threadingHeaders(
+ payload.inReplyTo,
+ payload.references.filter((id) => id !== payload.inReplyTo)
+ );
+
+ // Fail closed like every other send: only an exact "false" sends.
+ if (env.EMAIL_DRY_RUN !== "false") {
+ console.log(
+ "[EMAIL_DRY_RUN] would send email reply:\n" +
+ ` from: ${from.name} <${from.email}>\n` +
+ ` to: ${payload.to}\n` +
+ ` reply-to: ${replyTo}\n` +
+ ` subject: ${payload.subject}\n` +
+ ` headers: ${JSON.stringify(headers)}\n` +
+ ` task: ${payload.taskId} comment: ${payload.commentId}\n` +
+ ` body (markdown):\n${payload.body}`
+ );
+ return;
+ }
+
+ const binding = env.SEND_EMAIL;
+ if (!binding) {
+ throw new Error('EMAIL_DRY_RUN is off but the SEND_EMAIL binding is missing from wrangler.jsonc');
+ }
+ const base: EmailMessageBuilder = {
+ from,
+ to: payload.to,
+ replyTo,
+ subject: payload.subject,
+ text: payload.body,
+ html,
+ };
+ if (headers === null) {
+ await binding.send(base);
+ return;
+ }
+ await sendWithHeaders(binding, base, headers, (code) => {
+ console.warn(
+ JSON.stringify({
+ level: "warn",
+ msg: "email reply: threading headers were refused; resending without them",
+ taskId: payload.taskId,
+ commentId: payload.commentId,
+ code,
+ })
+ );
+ });
+}
+
// --- tiny markdown renderer -------------------------------------------------
const escapeHtml = (s: string) =>
diff --git a/apps/api/wrangler.example.jsonc b/apps/api/wrangler.example.jsonc
index 166fa45..935e41a 100644
--- a/apps/api/wrangler.example.jsonc
+++ b/apps/api/wrangler.example.jsonc
@@ -83,7 +83,12 @@
// Acknowledge new tickets (only to senders Cloudflare authenticated). Leave
// "false" until a real routed message shows Cloudflare's
// Authentication-Results header in the intake logs.
- "EMAIL_INTAKE_ACK": "false"
+ "EMAIL_INTAKE_ACK": "false",
+ // Let an api key an owner or admin allowed (PATCH /api/api-keys/:id with
+ // {"canEmailRequesters": true}) email a comment back to the sender who
+ // opened a ticket: POST /api/tasks/:id/comments with
+ // {"emailToRequester": true}. Never over MCP. Off until you set "true".
+ "EMAIL_INTAKE_REPLY": "false"
// GLEAP_PROJECT_TOKENS_JSON (JSON object mapping Gleap project ids to
// their screenshot API tokens) is a Worker secret, never a var. Local
// development may put it in the gitignored .dev.vars file when
From eadf4071a51ff6064df1ac58e66fe56f3b2a9e7b Mon Sep 17 00:00:00 2001
From: Joel Borchardt <121824267+joelborch@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:45:52 -0400
Subject: [PATCH 3/6] =?UTF-8?q?Show=20"Emailed=20to=20=E2=80=A6"=20under?=
=?UTF-8?q?=20comments=20that=20went=20to=20the=20requester?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An emailed comment shows who it went to and where delivery stands:
"Sending to", "Emailed to", or "Email to … failed". Sending is API-only;
the comment box itself is unchanged.
Co-Authored-By: Claude Opus 5.5
---
apps/web/src/shell/ui.tsx | 4 ++++
apps/web/src/task/Comments.test.tsx | 30 +++++++++++++++++++++++++++++
apps/web/src/task/Comments.tsx | 24 ++++++++++++++++++++++-
3 files changed, 57 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/shell/ui.tsx b/apps/web/src/shell/ui.tsx
index ded861c..1e3d3d2 100644
--- a/apps/web/src/shell/ui.tsx
+++ b/apps/web/src/shell/ui.tsx
@@ -115,6 +115,10 @@ export const Search = svg([
,
,
]);
+export const MailIcon = svg([
+ ,
+ ,
+]);
export const Inbox = svg([
,
,
diff --git a/apps/web/src/task/Comments.test.tsx b/apps/web/src/task/Comments.test.tsx
index a73cba6..87c11ae 100644
--- a/apps/web/src/task/Comments.test.tsx
+++ b/apps/web/src/task/Comments.test.tsx
@@ -95,3 +95,33 @@ describe("Comments", () => {
users.value = [];
});
});
+
+describe("EmailedNote", () => {
+ const emailed = (emailStatus: Comment["emailStatus"]): Comment => ({
+ id: "cm_9",
+ taskId: "tk_1",
+ authorId: "us_blake",
+ body: "Done",
+ createdAt: 9,
+ via: "ui",
+ emailTo: "pat@clinic.com",
+ emailStatus,
+ });
+
+ it("says who got the comment and where delivery stands", () => {
+ const cases: Array<[Comment["emailStatus"], string]> = [
+ ["sending", "Sending to pat@clinic.com…"],
+ ["sent", "Emailed to pat@clinic.com"],
+ ["failed", "Email to pat@clinic.com failed"],
+ ];
+ for (const [status, text] of cases) {
+ act(() => render(, root));
+ expect(root.textContent).toContain(text);
+ }
+ });
+
+ it("shows nothing on an internal comment", () => {
+ act(() => render(, root));
+ expect(root.textContent).not.toMatch(/Emailed|Sending to|failed/);
+ });
+});
diff --git a/apps/web/src/task/Comments.tsx b/apps/web/src/task/Comments.tsx
index f0ecd2a..0abd1a6 100644
--- a/apps/web/src/task/Comments.tsx
+++ b/apps/web/src/task/Comments.tsx
@@ -6,7 +6,7 @@ import type { Comment } from "@flow/shared";
import { me } from "../store/index.js";
import { userById } from "../shell/data.js";
import { formatDateTime, relativeTime } from "../shell/format.js";
-import { Avatar, SectionLabel } from "../shell/ui.js";
+import { Avatar, MailIcon, SectionLabel } from "../shell/ui.js";
import { Markdown } from "./markdown.js";
import { isSubmitChord, useAutogrow } from "./autogrow.js";
@@ -16,6 +16,27 @@ export function isAgentComment(comment: Comment): boolean {
return comment.via === "api" || comment.via === "mcp";
}
+/** The line under an emailed comment: who got it and where delivery stands. */
+export function EmailedNote({ comment }: { comment: Comment }) {
+ if (comment.emailTo === null || comment.emailTo === undefined) return null;
+ const failed = comment.emailStatus === "failed";
+ const label =
+ comment.emailStatus === "sent"
+ ? `Emailed to ${comment.emailTo}`
+ : failed
+ ? `Email to ${comment.emailTo} failed`
+ : `Sending to ${comment.emailTo}…`;
+ return (
+
+
+ {label}
+
+ );
+}
+
export function Comments({ comments }: { comments: Comment[] }) {
const rows = [...comments].sort((a, b) => a.createdAt - b.createdAt);
@@ -60,6 +81,7 @@ export function Comments({ comments }: { comments: Comment[] }) {
+
);
From 0feabf8e5dcc036963b05f8feb5a51557a5d9dc2 Mon Sep 17 00:00:00 2001
From: Joel Borchardt <121824267+joelborch@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:45:53 -0400
Subject: [PATCH 4/6] Document the catch-up: intake, handoffs, replies,
backups, set_due_date
API.md gains handoffs, email intake, backups, the emailToRequester option,
PATCH /api/api-keys/:id and subtask notes; /api/health documents backup
staleness. AGENTS_AND_MCP.md covers the three handoff tools and the trust
model. AUTOMATIONS.md adds set_due_date. SELF_HOSTING.md walks through
email intake setup (a dedicated domain, the catch-all rule, verifying the
auth header before turning on acknowledgements, and replies) and the new
backup and retention behaviour. The package READMEs pick up the job runner,
pruning, intake and reply internals, and the root README lists the new
features.
Co-Authored-By: Claude Opus 5.5
---
README.md | 11 +++++---
apps/api/README.md | 22 +++++++++++++++-
docs/AGENTS_AND_MCP.md | 17 ++++++++++---
docs/API.md | 43 ++++++++++++++++++++++++++++---
docs/AUTOMATIONS.md | 3 ++-
docs/SELF_HOSTING.md | 17 +++++++++++--
packages/core/README.md | 56 ++++++++++++++++++++++++++++++++++++-----
7 files changed, 149 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index 66ba7c8..1dcded8 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ One Cloudflare Worker serves the SPA, the REST API, an MCP server, and WebSocket
Task managers are slow, expensive per seat, and hostile to agents. Flow inverts all three:
-- **Agent-first.** A built-in MCP server at `/mcp` exposes 15 tools with proper annotations and structured output. Every tool calls the same Durable Object RPC method the UI and REST routes call, so a task created by Claude fires exactly the automations a task created by a human does. API keys impersonate real users, and the audit trail records which key did what — you always know whether a change came from a person or their agent.
+- **Agent-first.** A built-in MCP server at `/mcp` exposes 18 tools with proper annotations and structured output. Every tool calls the same Durable Object RPC method the UI and REST routes call, so a task created by Claude fires exactly the automations a task created by a human does. API keys impersonate real users, and the audit trail records which key did what — you always know whether a change came from a person or their agent.
- **Fast in a way SaaS can't be.** The whole workspace lives in one Durable Object's SQLite, so reads are single-digit-millisecond, in-process queries — no network hop to a database. The client gets one snapshot, then applies deltas over a WebSocket forever; there is no "refetch the board" path anywhere in the codebase. Mutations are optimistic in the UI, and a localStorage boot cache paints the last known board before the bundle has even finished loading.
- **Yours.** Your tasks live in your Cloudflare account, in SQLite you can query, with an audit log of every mutation. No seat pricing, no data egress negotiation, no vendor deciding which API endpoints agents are allowed to use.
@@ -18,12 +18,15 @@ Task managers are slow, expensive per seat, and hostile to agents. Flow inverts
- **Boards** with per-list status pipelines (exactly one `open` status first, one `closed` last, custom statuses between), drag-and-drop with fractional positions, filters, and a command palette
- **Tasks** with markdown descriptions, single assignee, priority, due/start dates, tags, snooze ("waiting on…" with a wake time), and comments
-- **Asana-style subtasks** — lightweight done/not-done checklist steps with optional assignee and due date, deliberately not miniature tasks with their own status pipeline
+- **Asana-style subtasks** — lightweight done/not-done checklist steps with optional assignee, due date and a plain-text note, deliberately not miniature tasks with their own status pipeline
+- **My Work** — everything assigned to you, subtasks included, bucketed by due date, plus a rail of work you've assigned out
- **Real-time sync** — delta protocol with a monotonic `seq`, replay on reconnect, hibernating WebSockets so idle connections cost nothing
-- **MCP server** — streamable HTTP at `/mcp`, 15 tools, structured output, per-tool annotations, error messages written so agents can self-correct
+- **MCP server** — streamable HTTP at `/mcp`, 18 tools, structured output, per-tool annotations, error messages written so agents can self-correct
- **REST API** — the full surface, curl-friendly, with per-item results on bulk operations (up to 200 at a time)
- **Automations** — trigger → conditions → actions rules evaluated inline in the mutation turn (depth-capped at 5), with a per-run log; outbound webhooks (HMAC-signed) and email go through a queue
- **Email notifications** — assigned-to-me, comment-on-my-task, status changes, per-user preferences, sent via Cloudflare Email Sending with a dry-run gate that defaults to on
+- **Agent handoffs** — one person's agent files a request for a teammate's agent as a task with a state (requested → working / needs input → done / declined), over MCP or REST, with the trust model spelled out in the tool descriptions
+- **Email intake** — each list can take tickets at `@your-domain` from allowlisted senders, with threading (replies become comments), Cloudflare-verified sender authentication, optional acknowledgements, and comments you can email back to the requester
- **Inbound webhooks** — per-list `inb_` tokens so a bug tracker or form tool can create tasks in exactly one list, with idempotent delivery
- **Per-space visibility** — spaces are workspace-visible or private to a member list; snapshots, deltas, search results, and writes all enforce it
- **Audit log** — every mutation records who, via what (`ui`/`api`/`mcp`/`webhook`/`automation`/`import`), which API key, which automation rule, and the diff
@@ -118,7 +121,7 @@ That's the whole integration. The agent gets workspace orientation, search, task
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | The single-DO decision and its limits, the delta protocol, snapshot slimming, the automations engine, auth model, performance techniques |
| [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md) | End-to-end deployment runbook: Cloudflare setup, Access, email, R2, Queues, bootstrap |
-| [docs/AGENTS_AND_MCP.md](docs/AGENTS_AND_MCP.md) | MCP server, the 15 tools, API keys and impersonation, webhooks in and out |
+| [docs/AGENTS_AND_MCP.md](docs/AGENTS_AND_MCP.md) | MCP server, the 18 tools, agent handoffs, API keys and impersonation, webhooks in and out |
| [docs/API.md](docs/API.md) | REST reference, every route with method, auth, and a curl example |
| [docs/AUTOMATIONS.md](docs/AUTOMATIONS.md) | The rule vocabulary: triggers, conditions, actions, templates, the run log |
| [docs/IMPORTING.md](docs/IMPORTING.md) | Migrating from ClickUp; where Asana/Trello extractors would go |
diff --git a/apps/api/README.md b/apps/api/README.md
index 2b5705d..db34ce6 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -674,6 +674,26 @@ flowcurl -X PUT $FLOW/api/lists/ls_def456/email-intake \
flowcurl -X DELETE $FLOW/api/lists/ls_def456/email-intake
```
+**Replying by email.** With `EMAIL_INTAKE_REPLY="true"`, a comment posted with
+`"emailToRequester": true` also goes to the sender whose email opened the
+ticket, verified or not (the address is always on the allowlist, so a forger
+never receives the reply): from `EMAIL_FROM`, as `Re: `, threaded on
+their messages, with Reply-To on the intake address. The queue consumer sends
+it, then marks the comment `sent`; a dead-lettered send marks it `failed`, and
+a failed report never retries into a second email. A person in the app can
+send one; an api key needs `canEmailRequesters`, which an owner or admin sets
+with `PATCH /api/api-keys/:id`; MCP never can.
+
+```bash
+flowcurl -X PATCH $FLOW/api/api-keys/ak_abc123 \
+ -H 'Content-Type: application/json' -d '{"canEmailRequesters":true}'
+
+flowcurl -X POST $FLOW/api/tasks/tk_ghi789/comments \
+ -H 'Content-Type: application/json' \
+ -d '{"body":"Done, here it is: https://example.com/page","emailToRequester":true}'
+# {"id":"cm_...","emailTo":"front.desk@example.org","emailStatus":"sending",...}
+```
+
### Import
Admin-only, and used by `apps/importer` during the ClickUp migration.
@@ -1024,7 +1044,7 @@ src/
mcp/context.ts per-request identity + memoised id -> name index
mcp/views.ts result shaping — ids to names (pure)
mcp/work.ts due-date bucketing for flow_list_my_work (pure)
- side-effects/ owned by the automations agent
+ side-effects/ queue consumer: webhook + email delivery, retries, DLQ
```
`src/do.ts` is worth reading first if you are touching the DO: it is the full
diff --git a/docs/AGENTS_AND_MCP.md b/docs/AGENTS_AND_MCP.md
index a13001c..32118a3 100644
--- a/docs/AGENTS_AND_MCP.md
+++ b/docs/AGENTS_AND_MCP.md
@@ -65,7 +65,7 @@ answers "what did this agent do", separately from what the human did themselves.
Members can mint, list, and revoke keys that act as themselves. Minting a key that impersonates *someone else* is an escalation and requires owner/admin.
-## The 15 tools
+## The 18 tools
Tool names are declared in `MCP_TOOLS` in `packages/shared/src/api.ts` and asserted at server construction, so a drifted name fails loudly instead of quietly vanishing from `tools/list`.
@@ -82,10 +82,13 @@ Tool names are declared in `MCP_TOOLS` in `packages/shared/src/api.ts` and asser
| `flow_bulk_update_tasks` | Up to 200 updates, one ok/error result per `taskId`. |
| `flow_create_subtasks` | Up to 100 subtasks under one parent. |
| `flow_toggle_subtask` | Done / not done. |
-| `flow_comment_on_task` | A markdown comment, authored as the key's user. |
+| `flow_comment_on_task` | A markdown comment, authored as the key's user. It can't email anyone: emailing a ticket's requester is REST-only, for keys an admin allowed. |
| `flow_list_automations` | Rules with their triggers, conditions, and actions. |
| `flow_upsert_automation` | Create or **replace** a rule (owner/admin, same as REST). |
| `flow_get_audit_log` | Who changed what, filterable and pageable. |
+| `flow_list_handoffs` | Handoffs waiting on you (`direction: "incoming"`, the default) or ones you filed (`"outgoing"`), open states by default. |
+| `flow_request_handoff` | File a request for a teammate's agent: `listId`, `toUserId`, `title`, `ask`, optional `returnFormat`, `contextTaskIds` and `dueDate`. |
+| `flow_reply_handoff` | Reply and/or move a handoff: `needs_input` to ask back, `done` with the answer, `declined` with the reason. |
Every mutation calls the same DO RPC method the matching REST route calls — there is no separate MCP write path to keep in sync, and automations fire identically regardless of caller.
@@ -100,7 +103,7 @@ Every mutation calls the same DO RPC method the matching REST route calls — th
### Annotations and structured output
-Every tool carries an explicit `annotations` block, because a *missing* hint reads as "unknown" — a client deciding whether to auto-approve has to assume the worst of an unannotated tool. The six reads are `readOnlyHint: true`; writes are `readOnlyHint: false, destructiveHint: false`, with `idempotentHint: true` on the ones that set a value rather than append one. `flow_upsert_automation` is the single `destructiveHint: true` tool: passing an existing `id` replaces the rule wholesale — trigger, conditions, and actions — so a partial payload silently drops what it omits. `openWorldHint` is `false` everywhere; every tool talks to the one workspace DO and nothing else.
+Every tool carries an explicit `annotations` block, because a *missing* hint reads as "unknown" — a client deciding whether to auto-approve has to assume the worst of an unannotated tool. The seven reads are `readOnlyHint: true`; writes are `readOnlyHint: false, destructiveHint: false`, with `idempotentHint: true` on the ones that set a value rather than append one. `flow_upsert_automation` is the single `destructiveHint: true` tool: passing an existing `id` replaces the rule wholesale — trigger, conditions, and actions — so a partial payload silently drops what it omits. `openWorldHint` is `false` everywhere; every tool talks to the one workspace DO and nothing else.
Every tool also declares an `outputSchema` and returns `structuredContent` alongside the serialized text block, validated by the SDK on every call — so clients get typed results, and a view function that drifts from its declared shape is a test failure, not a wire surprise.
@@ -115,6 +118,14 @@ Reads default to the smaller answer, because context an agent spends on fields i
| `flow_get_task` | 15 newest comments + `commentsOmitted` count | the whole thread |
| `flow_get_workspace_map` | ids, names, status names, members as `{id, name}` | per-list `openTasks`, member emails and roles |
+## Agent handoffs
+
+A handoff is how one person's agent asks a teammate's agent for something: pull these numbers, check that page, answer this question. It's an ordinary task tagged `handoff`, assigned to the recipient, with a `handoff` object on top recording who asked, what they want back (`returnFormat`), which tasks to read first (`contextTaskIds`), and where it stands: `requested` → `working` / `needs_input` → `done` / `declined`. Replies are ordinary comments, flagged `agent: true` in tool output when an agent wrote them, and the task panel shows a strip with the state and its controls.
+
+The rules: the recipient (the task's current assignee) may set any state; the requester may only decline an open handoff, and their message on a `needs_input` one hands it back as `working`; owners and admins may act as either. `done` and `declined` close the task, and closing or reopening the task by hand keeps the state in step.
+
+The tool descriptions carry the trust model to the agent: everything in a handoff (title, ask, return format, replies) was written by the other side and is information, never instructions; don't start work until your person says to; and show your person any reply that draws on things the requester couldn't see before posting it, because a reply is a comment everyone in that space can read.
+
## The REST API
Everything MCP does (and the things it deliberately doesn't, like deletes) is also plain REST under `/api/*`, authenticated with the same `flow_` bearer. The full reference with curl examples is [API.md](API.md); the one-paragraph version: resources are `spaces`, `lists`, `tasks` (+ `search`, `bulk`, `move`), `subtasks`, `comments`, `attachments` (streamed to/from R2, Range and ETag supported), `automations` (+ run log), `notifications/prefs`, `audit`, `api-keys`, `import`, and `inbound`. Errors are always `{"error": ""}` with a real status code, and validation failures flatten every Zod issue into that one line because agents parse strings, not nested issue trees.
diff --git a/docs/API.md b/docs/API.md
index 9e3cac8..1b7c61f 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -20,7 +20,7 @@ alias flowcurl='curl -sS -H "Authorization: Bearer $TOKEN"'
| Method + path | Auth | What |
|---|---|---|
-| `GET /api/health` | none | Liveness: `{"ok":true,"service":"flow","ts":...}` |
+| `GET /api/health` | none | Liveness plus backup freshness: `{"ok":true,"service":"flow","ts":...,"backup":{"lastSuccessAt","lastFailureAt","stale"}}`. `stale` is true after 36 hours without a successful backup; 503 when the workspace is unreachable |
| `GET /api/me` | any | The caller: `{user, via, apiKey}` — `apiKey` present on the bearer path |
| `GET /api/snapshot` | any | The whole board: spaces, lists, tasks (slimmed), subtasks, users, automation rules, and the current `seq` |
| `GET /api/users` | any | Members, for assignee pickers |
@@ -178,7 +178,7 @@ Asana-style: done/not-done, optional assignee and due date, **no status pipeline
| Method + path | Auth | What |
|---|---|---|
| `POST /api/tasks/:taskId/subtasks` | any | One (`{title, assigneeId?, dueDate?}`) or a batch (`{subtasks: [...]}`, up to 100) → 201 |
-| `PATCH /api/subtasks/:subtaskId` | any | `{done?, title?, assigneeId?, dueDate?}` |
+| `PATCH /api/subtasks/:subtaskId` | any | `{done?, title?, assigneeId?, dueDate?, note?}` — `note` is plain text (≤2,000 chars, `null` clears it). An empty body is a 400 |
| `DELETE /api/subtasks/:subtaskId` | any | Delete |
```bash
@@ -194,9 +194,18 @@ flowcurl -X PATCH $FLOW/api/subtasks/sb_jkl012 \
| Method + path | Auth | What |
|---|---|---|
| `GET /api/tasks/:taskId/comments` | any | The thread |
-| `POST /api/tasks/:taskId/comments` | any | `{body}` (markdown, ≤20,000 chars) → 201 |
+| `POST /api/tasks/:taskId/comments` | any | `{body, emailToRequester?}` (markdown, ≤20,000 chars) → 201 |
| `DELETE /api/comments/:commentId` | any | Delete |
+A comment records how it arrived in `via` (`"ui"`, `"api"`, `"mcp"`, …), so a thread can tell a person from their agent.
+
+**Emailing a reply.** On a task opened by [email intake](#email-intake), `emailToRequester: true` also emails the comment to the sender whose email opened the ticket: from `EMAIL_FROM`, in their original thread (`Re: `), with Reply-To on the intake address so their answer lands back on the task. The comment records `emailTo` and `emailStatus` (`sending`, then `sent` or `failed`). It needs `EMAIL_INTAKE_REPLY="true"`, and a person signed in to the app or an API key an admin allowed (`canEmailRequesters`, below); MCP never can. `GET /api/tasks/:taskId` returns `emailReplyTo` and `emailReplySubject`, so a client can show exactly what would be sent before sending it.
+
+```bash
+flowcurl -X POST $FLOW/api/tasks/tk_ghi789/comments -H 'Content-Type: application/json' \
+ -d '{"body":"Done, here it is: https://example.com/page","emailToRequester":true}'
+```
+
## Attachments
Uploads are raw bytes streamed straight into R2 — nothing buffers in the Worker, so a 100 MB file never approaches the memory limit. `Content-Length` is required; 100 MB is a hard cap (413). Filename comes from `?filename=` or an `X-Filename` header.
@@ -274,6 +283,7 @@ Self-serve for your own identity; impersonating another user is admin-only.
|---|---|---|
| `POST /api/api-keys` | `userId` absent or own id | any `userId` |
| `GET /api/api-keys` | own keys only | every key |
+| `PATCH /api/api-keys/:apiKeyId` | — | `{canEmailRequesters}`: allow or stop the key emailing ticket requesters |
| `DELETE /api/api-keys/:apiKeyId` | own keys only | any key |
```bash
@@ -284,6 +294,33 @@ flowcurl -X POST $FLOW/api/api-keys -H 'Content-Type: application/json' \
# "token":"flow_...","warning":"This token is shown only once and cannot be recovered."}
```
+## Agent handoffs
+
+A handoff is a task one person's agent files for a teammate's agent: the title and `ask` (the description) carry the request, the assignee is the recipient, and the task carries `handoff: {requestedBy, state, returnFormat, contextTaskIds, stateChangedAt}`. Replies are ordinary comments. See [AGENTS_AND_MCP.md](AGENTS_AND_MCP.md#agent-handoffs).
+
+| Method + path | Auth | What |
+|---|---|---|
+| `POST /api/handoffs` | any | `{listId, toUserId, title, ask, returnFormat?, contextTaskIds?, dueDate?}` → 201, the new task |
+| `GET /api/handoffs` | any | `?direction=incoming\|outgoing&states=requested,working&limit=50` — incoming (assigned to you) by default, open states by default |
+| `POST /api/tasks/:taskId/handoff` | any | `{message?, state?}` — reply and/or move it: `working`, `needs_input`, `done`, `declined` (done and declined close the task) |
+
+## Email intake
+
+A list can take tickets by email at `@EMAIL_INTAKE_DOMAIN`. Mail from senders on its allowlist (addresses or whole `@domains`) becomes a task tagged `email`; a reply in the same thread becomes a comment. Mail Cloudflare couldn't authenticate is also tagged `unverified`. Setup is in [SELF_HOSTING.md](SELF_HOSTING.md#email-intake).
+
+| Method + path | Auth | What |
+|---|---|---|
+| `GET /api/email-intakes` | admin | `{domain, intakes: [{listId, alias, allowedSenders, ...}]}` |
+| `PUT /api/lists/:listId/email-intake` | admin | `{alias, allowedSenders}` — create or replace the list's intake |
+| `DELETE /api/lists/:listId/email-intake` | admin | Remove it; the alias is free again |
+
+## Backups
+
+| Method + path | Auth | What |
+|---|---|---|
+| `GET /api/admin/backup` | admin | Full backup status, including the last error and object key that `/api/health` leaves out |
+| `POST /api/admin/backup` | admin | Run a backup now (gzipped NDJSON to R2 `backups/YYYY-MM-DD.ndjson.gz`) |
+
## Import
Admin-only; used by `apps/importer`. Import upserts by id (then `clickupId`), fires no automations, emits no deltas, and truncates over-long titles instead of rejecting them — see [IMPORTING.md](IMPORTING.md).
diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md
index bbda8a6..92f9f4e 100644
--- a/docs/AUTOMATIONS.md
+++ b/docs/AUTOMATIONS.md
@@ -48,13 +48,14 @@ All conditions must hold (AND). Each matches against the task **after** the trig
## Actions
-Actions run in order. The first nine mutate the workspace **in the same turn**; the last two enqueue outbound I/O.
+Actions run in order. The first ten mutate the workspace **in the same turn**; the last two enqueue outbound I/O.
| Action | Effect | Parameters |
|---|---|---|
| `set_status` | Move the task to a status, by name. | `statusName` |
| `set_assignee` | Assign (or `null` to unassign). | `userId` |
| `set_priority` | Set priority (or `null` to clear). | `priority` |
+| `set_due_date` | Set the due date from when the rule fires: the same day before `cutoffHour` (local to `timeZone`), the next day from `cutoffHour` on. With `businessDays` (default `true`) a Saturday or Sunday result rolls to Monday. Stored at local noon, like the date picker. For example, email tickets that arrive after 10 a.m. are due the next business day. | `cutoffHour` (0–23), `timeZone`, `businessDays` |
| `add_tags` | Merge tags in, case-insensitively deduplicated; a no-op change emits nothing. | `tags` |
| `remove_tags` | Remove tags, case-insensitively matched; a no-op change emits nothing. | `tags` |
| `create_next_recurring_task` | When a recurring task closes, create its next occurrence on the configured cadence. | `recurrence` (`weekdays`/`weekly`/`monthly`/`yearly`, with `interval` on the latter three), `timeZone`, `identityTag`, `statusName` |
diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md
index f4edbc5..34da4fb 100644
--- a/docs/SELF_HOSTING.md
+++ b/docs/SELF_HOSTING.md
@@ -205,6 +205,19 @@ See [AGENTS_AND_MCP.md](AGENTS_AND_MCP.md) for other clients and the full tool r
If you're migrating, run the importer now, before the team starts creating tasks — see [IMPORTING.md](IMPORTING.md). The import is idempotent, fires no automations, and streams attachments server-side into R2.
+## Email intake (optional)
+
+Each list can take tickets by email at `@`: mail from an allowlisted sender becomes a task tagged `email`, and replies in the thread become comments.
+
+1. **Pick a domain Email Routing can own outright.** Onboarding a zone's apex to Email Routing replaces its MX records, so don't use a domain that already receives company mail. A small dedicated domain (e.g. `help.example.com` as its own zone) is the clean setup.
+2. In the Cloudflare dashboard, enable Email Routing on that zone and point its **catch-all** rule at this Worker. Each list's alias then works without its own rule.
+3. Set `EMAIL_INTAKE_DOMAIN` in `wrangler.jsonc` and redeploy. It tells the UI and API which domain to show next to each alias.
+4. In **Settings → Inbound**, give a list an alias and a sender allowlist (exact addresses or whole `@domains`). Anything else gets a polite bounce.
+5. Send a test message and check `wrangler tail` for the `email intake auth` line. Mail Cloudflare authenticated (aligned DMARC, DKIM or SPF) is filed normally; anything else is tagged `unverified`. Once you've seen the header, you can set `EMAIL_INTAKE_ACK` to `"true"` to send authenticated senders a short acknowledgement, threaded on their message, with Reply-To on the intake address.
+6. Optional: `EMAIL_INTAKE_REPLY="true"` lets you email a comment back to the sender, either from the app or with an API key an admin allowed (`PATCH /api/api-keys/:id` with `{"canEmailRequesters": true}`, then `POST /api/tasks/:id/comments` with `{"emailToRequester": true}`). The comment is the email, and the task records "Emailed to …". Replies go to unverified senders too, since the address is always on the allowlist and a forger never receives the reply.
+
+Both acknowledgements and replies go out through `SEND_EMAIL` from `EMAIL_FROM`, so finish [Email sending](#6-email-sending) first.
+
## Local development
```bash
@@ -224,7 +237,7 @@ curl -s localhost:8787/api/me | jq
## Operations notes
-- **Backups.** The workspace is one SQLite database inside a Durable Object; Cloudflare's [point-in-time recovery](https://developers.cloudflare.com/durable-objects/api/sql-storage/) covers SQLite-backed DOs. For belt-and-braces, `GET /api/snapshot` plus the audit log gives you an application-level export you can cron from anywhere with an API key. The DO also runs its own daily backup job — every table gzipped to NDJSON (one `{"table":...,"row":...}` line per row) and written to the attachments R2 bucket under `backups/YYYY-MM-DD.ndjson.gz`, at 05:00 UTC, with the newest 30 objects retained. To restore, the file is just table/row NDJSON — read it, group by table, and re-insert.
+- **Backups.** The workspace is one SQLite database inside a Durable Object; Cloudflare's [point-in-time recovery](https://developers.cloudflare.com/durable-objects/api/sql-storage/) covers SQLite-backed DOs. For belt-and-braces, `GET /api/snapshot` plus the audit log gives you an application-level export you can cron from anywhere with an API key. The DO also runs its own daily backup job — every table gzipped to NDJSON (one `{"table":...,"row":...}` line per row) and written to the attachments R2 bucket under `backups/YYYY-MM-DD.ndjson.gz`, at 05:00 UTC, with the newest 30 objects retained. To restore, the file is just table/row NDJSON — read it, group by table, and re-insert. Every run records its outcome: `/api/health` reports `backup.stale` after 36 hours without a success, `GET /api/admin/backup` shows the last error, and `POST /api/admin/backup` runs one on demand.
- **Logs.** `observability.enabled` is on, so `pnpm exec wrangler tail` from `apps/api` streams structured logs — including `[EMAIL_DRY_RUN] would send:` blocks and automation failures.
-- **Retention.** The delta log keeps its newest 50,000 rows (pruned daily); a reconnect older than that gets a fresh snapshot instead of a replay. The audit log is not pruned.
+- **Retention.** The delta log keeps its newest 50,000 rows (pruned daily); a reconnect older than that gets a fresh snapshot instead of a replay. The automation run log keeps its newest 20,000 rows, and audit entries older than 365 days are deleted by the same daily job.
- **The DLQ.** Messages that fail 5 deliveries land in `flow-dlq`, where a consumer (`handleDeadLetterBatch`) logs each one and writes an `ok:false` row to the automation run log rather than dropping it silently — check the run log or `wrangler tail` when a webhook endpoint has been down.
diff --git a/packages/core/README.md b/packages/core/README.md
index a126ee5..a927696 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -245,6 +245,30 @@ field-named error. `handoff` is in `TASK_DIFF_KEYS`, and `diffOf` compares
object values by JSON, so a change ships the whole new object and an
untouched handoff never appears in a patch.
+## Email intake and replies
+
+The Worker's `email()` handler parses each message and hands it to
+**`ingestEmail(input, actor)`**, which decides in one turn what it is: a new
+task in the intake's list (tagged `email`, plus `unverified` when Cloudflare
+couldn't authenticate the sender), a comment on the task an earlier message
+opened (matched through `References`, without touching its status), a
+duplicate, or a refusal (unknown alias, sender not on the allowlist, either
+rate limit). The outcome and its `email_messages` row commit together, so a
+redelivered message can never file twice. Errors no retry can fix carry the
+`EMAIL_INGEST_PERMANENT_ERROR` prefix, so the Worker bounces them.
+
+**`createComment`** with `emailToRequester` also emails the comment back to
+the sender whose email opened the task. Before anything is written it checks
+that `EMAIL_INTAKE_REPLY` is on, that the actor is a person in the app or an
+api key with `canEmailRequesters` (never MCP), and that the task has a
+requester whose intake alias still exists. The comment then records `emailTo`
+and `emailStatus: "sending"`, and an `email-reply` side effect is queued with
+the alias (for Reply-To), a `Re: ` subject and the thread's
+Message-IDs. **`markEmailReply(commentId, status)`** is the queue consumer's
+report: it moves a `sending` comment to `sent` or `failed` once, and later
+reports are ignored. **`setApiKeyEmailRequesters`** (owner/admin, never over
+MCP) turns the permission on or off for one key.
+
## importBatch
```ts
@@ -304,20 +328,29 @@ a snapshot". It is sent after `importBatch` (to everyone), and after
`setSpaceVisibility` or a private space's `setSpaceMembers` (to the affected
non-admin connections only — a client that just lost access holds a subtree it
must forget, and one that just gained access has never seen the rows, and
-neither is expressible as a patch).
+neither is expressible as a patch). The same applies when any turn moves a task
+or list across spaces with a private end, including a `move_to_list`
+automation (`resyncForSpaceMoves`, run from `runTurn`), and to a user whose
+role crosses between member and owner/admin. Deactivating a user closes their
+sockets, and a deactivated user's `hello` is refused.
## Alarm and scheduled jobs
A DO gets one alarm, so `scheduled_jobs` multiplexes it. `alarm()` drains rows
-with `run_at <= now` (100 at a time), re-inserts recurring ones (`every_ms`,
-skipping missed intervals rather than firing a backlog) and re-arms to the next
-`run_at`. A job that throws is logged and dropped rather than wedging the
-schedule, since alarms retry.
+with `run_at <= now` (100 at a time, backup last), and claims each one before
+running it: recurring jobs are advanced to their next `run_at` (skipping missed
+intervals rather than firing a backlog), one-off jobs are deleted, and that
+write is made durable first. So a job that throws, or kills the isolate
+outright, costs only that one run instead of wedging the schedule. The alarm
+then re-arms to the next `run_at`. Each kind's last success and failure are
+kept in `job_status`; `getBackupStatus` reads the backup's, and `/api/health`
+reports it as `stale` after 36 hours without a success.
| Kind | Cadence | Work |
| --- | --- | --- |
| `due_date_check` | hourly | `sweepDueDateAutomations` — fires `due_date_approaching` rules; the engine's `(rule, task, dueDate)` guard makes it idempotent — **and** the snooze wake pass below |
-| `prune_changes` | daily, 09:00 UTC | Keep the last 50,000 `changes` rows |
+| `prune_changes` | daily, 09:00 UTC | Keep the last 50,000 `changes` rows, cap `automation_runs`, and delete `audit` rows older than 365 days (`AUDIT_RETENTION_DAYS`) |
+| `backup` | daily, 05:00 UTC | Gzipped NDJSON of every table to R2 `backups/YYYY-MM-DD.ndjson.gz` (30 kept), then sweep `pending_object_deletes` |
`scheduleJob({ runAt, kind, payload?, everyMs? })` adds more.
@@ -362,6 +395,17 @@ Tables: `users`, `spaces`, `lists`, `statuses`, `tasks`, `subtasks`, `comments`,
`automation_runs`, `automation_due_fires`, `changes`, `audit`, `scheduled_jobs`,
`tasks_fts`.
+`core-0014-email-replies` adds `comments.email_to` and `comments.email_status`
+(both NULL on an ordinary internal comment), `api_keys.can_email_requesters`
+(every key starts without it) and `email_messages.authenticated`, backfilled
+from each message's `email.ingest` audit row with anything the audit can't
+vouch for read as unverified.
+
+`core-0012-email-intake` adds `email_intakes` (one row per list that accepts
+mail; the alias is unique) and `email_messages`, the dedupe record keyed by
+(Message-ID, list) and written in the same turn as the task or comment it
+produced. The same rows drive threading and both hourly rate limits.
+
`core-0013-handoffs` adds five nullable columns to `tasks`
(`handoff_requested_by`, `handoff_state`, `handoff_return`, `handoff_context`
as a JSON array, `handoff_state_changed_at`) and `comments.via`. A NULL
From b7218eebe9a88d7554b2410f30bf235c820780dd Mon Sep 17 00:00:00 2001
From: Joel Borchardt <121824267+joelborch@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:58:38 -0400
Subject: [PATCH 5/6] Use reserved example domains and neutral fixtures
throughout
Test fixtures move from real registered domains to reserved ones
(clinic.example, lab.example), a real social post in the title-link test
becomes a placeholder, and a few comments and fixtures lose details that
only made sense for one deployment (dated incident notes, a specific
routing rule, references to an outside CLI). No behaviour changes.
Co-Authored-By: Claude Opus 5.5
---
apps/api/README.md | 2 +-
apps/api/src/email-intake.test.ts | 8 +-
apps/api/src/email-intake.ts | 2 +-
apps/api/src/routes/comments.test.ts | 2 +-
apps/api/src/side-effects/email-reply.test.ts | 12 +-
apps/web/src/lib/title-link.test.ts | 4 +-
apps/web/src/settings/EmailIntake.test.tsx | 20 +--
apps/web/src/store/mutations.ts | 2 +-
apps/web/src/task/Comments.test.tsx | 8 +-
packages/core/src/backup-job.test.ts | 2 +-
.../core/src/due-date-cutoff-storage.test.ts | 3 +-
.../core/src/email-intake-storage.test.ts | 132 +++++++++---------
packages/core/src/email-intake.test.ts | 22 +--
packages/core/src/email-reply-storage.test.ts | 38 ++---
packages/core/src/index.ts | 2 +-
packages/core/src/migrations.test.ts | 4 +-
packages/core/src/schema.ts | 4 +-
17 files changed, 133 insertions(+), 134 deletions(-)
diff --git a/apps/api/README.md b/apps/api/README.md
index db34ce6..b616513 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -405,7 +405,7 @@ flowcurl "$FLOW/api/handoffs?direction=outgoing&states=done,declined&limit=20"
# Reply: a message (posted as a comment), a new state, or both.
flowcurl -X POST $FLOW/api/tasks/tk_hnd123/handoff \
-H 'Content-Type: application/json' \
- -d '{"message":"| page | sessions | vs Feb |\n|---|---|---|\n| /implants | 1,204 | +8% |","state":"done"}'
+ -d '{"message":"| page | sessions | vs Feb |\n|---|---|---|\n| /pricing | 1,204 | +8% |","state":"done"}'
# {"task":{...},"comment":{...}} comment is null when only the state moved
```
diff --git a/apps/api/src/email-intake.test.ts b/apps/api/src/email-intake.test.ts
index b9659c6..0ac4a88 100644
--- a/apps/api/src/email-intake.test.ts
+++ b/apps/api/src/email-intake.test.ts
@@ -320,8 +320,8 @@ describe("authenticationLog", () => {
describe("titleFromSubject", () => {
it("collapses whitespace and strips reply/forward prefixes", () => {
expect(titleFromSubject("Re: FW: Printer \n broken")).toBe("Printer broken");
- expect(titleFromSubject("RE: Fwd: Re[2]: x-ray sensor")).toBe("x-ray sensor");
- expect(titleFromSubject("Recall notice")).toBe("Recall notice");
+ expect(titleFromSubject("RE: Fwd: Re[2]: printer jam")).toBe("printer jam");
+ expect(titleFromSubject("Renewal notice")).toBe("Renewal notice");
});
it("falls back for a blank or prefix-only subject", () => {
@@ -1302,13 +1302,13 @@ describe("deleteUploads", () => {
remove.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("R2 500")).mockResolvedValueOnce(undefined);
const uploads = Array.from({ length: 2500 }, (_, i) => ({
id: `at_${i}`,
- r2Key: `email/abc/at_${i}/patient-${i}.pdf`,
+ r2Key: `email/abc/at_${i}/private-name-${i}.pdf`,
}));
await deleteUploads(makeEnv(), uploads);
expect(remove.mock.calls.map((call) => (call[0] as string[]).length)).toEqual([1000, 1000, 500]);
const logged = errors.join("\n");
expect(logged).toContain("at_1000");
- expect(logged).not.toContain("patient-");
+ expect(logged).not.toContain("private-name-");
expect(logged).not.toContain("email/");
});
});
diff --git a/apps/api/src/email-intake.ts b/apps/api/src/email-intake.ts
index 7caf242..2106fda 100644
--- a/apps/api/src/email-intake.ts
+++ b/apps/api/src/email-intake.ts
@@ -834,7 +834,7 @@ export function planAttachments(parsed: Pick, mes
/**
* Best effort, in batches R2 accepts: an object left behind is unreferenced
* garbage, never a broken link. Logs ids and counts only, since keys and
- * filenames can name patients.
+ * filenames can name people.
*/
export async function deleteUploads(
env: Env,
diff --git a/apps/api/src/routes/comments.test.ts b/apps/api/src/routes/comments.test.ts
index fa0ec15..eb85440 100644
--- a/apps/api/src/routes/comments.test.ts
+++ b/apps/api/src/routes/comments.test.ts
@@ -122,7 +122,7 @@ describe("PATCH /api/api-keys/:apiKeyId", () => {
expect(body.apiKey).not.toHaveProperty("tokenHash");
});
- it("works from an owner's api key, since the CLI is where this is set", async () => {
+ it("works from an owner's api key, since that's how it's set without a switch in the app", async () => {
const fromKey = appWith(API_KEY, apiKey(false));
expect((await fromKey.patchKey({ canEmailRequesters: true })).status).toBe(200);
expect(fromKey.setApiKeyEmailRequesters).toHaveBeenCalledWith("ak_1", true, API_KEY);
diff --git a/apps/api/src/side-effects/email-reply.test.ts b/apps/api/src/side-effects/email-reply.test.ts
index dc0e1e1..ccb1d8e 100644
--- a/apps/api/src/side-effects/email-reply.test.ts
+++ b/apps/api/src/side-effects/email-reply.test.ts
@@ -21,12 +21,12 @@ type Reply = Extract;
function reply(over: Partial = {}): Reply {
return {
kind: "email-reply",
- to: "pat@clinic.com",
+ to: "pat@clinic.example",
alias: "support",
subject: "Re: Printer jammed",
body: "Done, here it is: https://example.com/page",
- inReplyTo: "second@clinic.com",
- references: ["first@clinic.com", "second@clinic.com"],
+ inReplyTo: "second@clinic.example",
+ references: ["first@clinic.example", "second@clinic.example"],
taskId: "tk_1",
commentId: "cm_1",
...over,
@@ -90,13 +90,13 @@ describe("deliverEmailReply", () => {
const message = send.mock.calls[0]![0];
expect(message).toMatchObject({
from: DEFAULT_EMAIL_FROM,
- to: "pat@clinic.com",
+ to: "pat@clinic.example",
replyTo: "support@help.example.com",
subject: "Re: Printer jammed",
text: "Done, here it is: https://example.com/page",
headers: {
- "In-Reply-To": "",
- References: " ",
+ "In-Reply-To": "",
+ References: " ",
},
});
expect(message.html).toContain("https://example.com/page");
diff --git a/apps/web/src/lib/title-link.test.ts b/apps/web/src/lib/title-link.test.ts
index 0f079cc..816bf3f 100644
--- a/apps/web/src/lib/title-link.test.ts
+++ b/apps/web/src/lib/title-link.test.ts
@@ -3,8 +3,8 @@ import { titleLink } from "./title-link.js";
describe("titleLink", () => {
it("returns a bare URL title as-is", () => {
- expect(titleLink("https://x.com/angeldot_/status/2103095295215100189")).toBe(
- "https://x.com/angeldot_/status/2103095295215100189"
+ expect(titleLink("https://x.com/example/status/1234567890")).toBe(
+ "https://x.com/example/status/1234567890"
);
});
diff --git a/apps/web/src/settings/EmailIntake.test.tsx b/apps/web/src/settings/EmailIntake.test.tsx
index bb8ed4f..0505416 100644
--- a/apps/web/src/settings/EmailIntake.test.tsx
+++ b/apps/web/src/settings/EmailIntake.test.tsx
@@ -32,7 +32,7 @@ const DOMAIN = "tickets.example.com";
const ACME: EmailIntake = {
listId: "li_acme",
alias: "acme",
- allowedSenders: ["dr@clinic.com", "@lab.com"],
+ allowedSenders: ["dr@clinic.example", "@lab.example"],
createdAt: 1,
updatedAt: 1,
};
@@ -170,8 +170,8 @@ describe("Email intake settings", () => {
expect(text).toContain("Email intake");
expect(text).toContain("Clinical / Acme");
expect(text).toContain("acme@tickets.example.com");
- expect(text).toContain("dr@clinic.com");
- expect(text).toContain("@lab.com");
+ expect(text).toContain("dr@clinic.example");
+ expect(text).toContain("@lab.example");
expect(button("Copy acme@tickets.example.com")).toBeTruthy();
});
@@ -212,14 +212,14 @@ describe("Email intake settings", () => {
select.dispatchEvent(new Event("change", { bubbles: true }));
});
typeInto(field('form input[aria-label="Alias"]'), " front-desk ");
- typeInto(field("form textarea"), " pat@clinic.com \n\n @clinic.org\n ");
+ typeInto(field("form textarea"), " pat@clinic.example \n\n @clinic.org\n ");
await click(button("Save"));
expect(putCalls()).toEqual([
{
method: "PUT",
url: "/api/lists/li_front/email-intake",
- body: { alias: "front-desk", allowedSenders: ["pat@clinic.com", "@clinic.org"] },
+ body: { alias: "front-desk", allowedSenders: ["pat@clinic.example", "@clinic.org"] },
},
]);
expect(root.textContent).toContain("Clinical / Front desk");
@@ -234,19 +234,19 @@ describe("Email intake settings", () => {
expect(field('form input[aria-label="Alias"]').value).toBe("acme");
const senders = field("form textarea");
- expect(senders.value).toBe("dr@clinic.com\n@lab.com");
+ expect(senders.value).toBe("dr@clinic.example\n@lab.example");
- typeInto(senders, "dr@clinic.com\n \n @lab.com \nnew@clinic.com ");
+ typeInto(senders, "dr@clinic.example\n \n @lab.example \nnew@clinic.example ");
await click(button("Save"));
expect(putCalls()).toEqual([
{
method: "PUT",
url: "/api/lists/li_acme/email-intake",
- body: { alias: "acme", allowedSenders: ["dr@clinic.com", "@lab.com", "new@clinic.com"] },
+ body: { alias: "acme", allowedSenders: ["dr@clinic.example", "@lab.example", "new@clinic.example"] },
},
]);
- expect(root.textContent).toContain("new@clinic.com");
+ expect(root.textContent).toContain("new@clinic.example");
expect(root.querySelector("form")).toBeNull();
});
@@ -262,7 +262,7 @@ describe("Email intake settings", () => {
select.dispatchEvent(new Event("change", { bubbles: true }));
});
typeInto(field('form input[aria-label="Alias"]'), "acme");
- typeInto(field("form textarea"), "pat@clinic.com");
+ typeInto(field("form textarea"), "pat@clinic.example");
await click(button("Save"));
expect(putCalls()).toHaveLength(1);
diff --git a/apps/web/src/store/mutations.ts b/apps/web/src/store/mutations.ts
index bed433b..c68a60f 100644
--- a/apps/web/src/store/mutations.ts
+++ b/apps/web/src/store/mutations.ts
@@ -496,7 +496,7 @@ async function loadTaskDetail(taskId: string): Promise {
subtasks: subtasks.value.get(taskId) ?? [],
comments: comments.value.get(taskId) ?? [],
attachments: [],
- // Unknown offline; only the CLI reads these anyway.
+ // Unknown offline; only API clients read these anyway.
emailReplyTo: null,
emailReplySubject: null,
};
diff --git a/apps/web/src/task/Comments.test.tsx b/apps/web/src/task/Comments.test.tsx
index 87c11ae..f7f349c 100644
--- a/apps/web/src/task/Comments.test.tsx
+++ b/apps/web/src/task/Comments.test.tsx
@@ -104,15 +104,15 @@ describe("EmailedNote", () => {
body: "Done",
createdAt: 9,
via: "ui",
- emailTo: "pat@clinic.com",
+ emailTo: "pat@clinic.example",
emailStatus,
});
it("says who got the comment and where delivery stands", () => {
const cases: Array<[Comment["emailStatus"], string]> = [
- ["sending", "Sending to pat@clinic.com…"],
- ["sent", "Emailed to pat@clinic.com"],
- ["failed", "Email to pat@clinic.com failed"],
+ ["sending", "Sending to pat@clinic.example…"],
+ ["sent", "Emailed to pat@clinic.example"],
+ ["failed", "Email to pat@clinic.example failed"],
];
for (const [status, text] of cases) {
act(() => render(, root));
diff --git a/packages/core/src/backup-job.test.ts b/packages/core/src/backup-job.test.ts
index 0f729d2..ad5abea 100644
--- a/packages/core/src/backup-job.test.ts
+++ b/packages/core/src/backup-job.test.ts
@@ -175,7 +175,7 @@ describe("backup job", () => {
expect(spaceRow?.row.name).toBe("Space A");
});
- it("excludes leading-underscore tables — prod's sqlite_master exposes the runtime's _cf_KV, which is prohibited to SELECT and killed every backup until 2026-08-17", async () => {
+ it("excludes leading-underscore tables — prod's sqlite_master exposes the runtime's _cf_KV, which is prohibited to SELECT and used to kill every backup", async () => {
// node:sqlite has no _cf_KV, so fabricate one the way workerd exposes it.
// If the filter regresses, this table shows up in the dump (here) and the
// whole backup throws (in prod).
diff --git a/packages/core/src/due-date-cutoff-storage.test.ts b/packages/core/src/due-date-cutoff-storage.test.ts
index 813cbfe..f314dd8 100644
--- a/packages/core/src/due-date-cutoff-storage.test.ts
+++ b/packages/core/src/due-date-cutoff-storage.test.ts
@@ -4,8 +4,7 @@ import { CreateListInput, CreateSpaceInput, CreateTaskInput } from "@flow/shared
// ---------------------------------------------------------------------------
// set_due_date: a ticket that arrives before the cutoff is due that day, one
-// that arrives later is due the next business day — the rule the Acme email
-// intake runs alongside its assign-to-Avery action. Same real-Workspace-over-
+// that arrives later is due the next business day. Same real-Workspace-over-
// node:sqlite harness as visibility-storage.test.ts.
// ---------------------------------------------------------------------------
diff --git a/packages/core/src/email-intake-storage.test.ts b/packages/core/src/email-intake-storage.test.ts
index b49f660..eca6c70 100644
--- a/packages/core/src/email-intake-storage.test.ts
+++ b/packages/core/src/email-intake-storage.test.ts
@@ -108,8 +108,8 @@ async function seeded() {
const support = ws.createList(CreateListInput.parse({ spaceId: space.id, name: "Support" }), OWNER);
const billing = ws.createList(CreateListInput.parse({ spaceId: space.id, name: "Billing" }), OWNER);
const plain = ws.createList(CreateListInput.parse({ spaceId: space.id, name: "Plain" }), OWNER);
- ws.setEmailIntake(support.id, { alias: "support", allowedSenders: ["@clinic.com"] }, OWNER);
- ws.setEmailIntake(billing.id, { alias: "billing", allowedSenders: ["@clinic.com"] }, OWNER);
+ ws.setEmailIntake(support.id, { alias: "support", allowedSenders: ["@clinic.example"] }, OWNER);
+ ws.setEmailIntake(billing.id, { alias: "billing", allowedSenders: ["@clinic.example"] }, OWNER);
/** A live owner socket, so tests can see what ingestEmail broadcast. */
const frames: ServerMsg[] = [];
@@ -120,14 +120,14 @@ async function seeded() {
} as unknown as WebSocket);
let n = 0;
- /** A well-formed, authenticated message to "support" from pat@clinic.com, with overrides. */
+ /** A well-formed, authenticated message to "support" from pat@clinic.example, with overrides. */
const mail = (over: Partial = {}): IngestEmailInput => {
n += 1;
return {
alias: "support",
- sender: "pat@clinic.com",
+ sender: "pat@clinic.example",
authenticated: true,
- messageId: `m${n}@clinic.com`,
+ messageId: `m${n}@clinic.example`,
referenceIds: [],
task: { title: `Ticket ${n}`, description: `Body ${n}` },
commentBody: `Reply ${n}`,
@@ -151,13 +151,13 @@ describe("setEmailIntake", () => {
const { ws, plain, auditActions } = await seeded();
const intake = ws.setEmailIntake(
plain.id,
- { alias: " Front-Desk ", allowedSenders: ["Pat@Clinic.com", "pat@clinic.com", "@Clinic.com"] },
+ { alias: " Front-Desk ", allowedSenders: ["Pat@Clinic.example", "pat@clinic.example", "@Clinic.example"] },
ADMIN
);
expect(intake).toMatchObject({
listId: plain.id,
alias: "front-desk",
- allowedSenders: ["pat@clinic.com", "@clinic.com"],
+ allowedSenders: ["pat@clinic.example", "@clinic.example"],
});
expect(ws.listEmailIntakes().find((i) => i.listId === plain.id)).toEqual(intake);
expect(auditActions()).toContain("list.email_intake.set");
@@ -173,14 +173,14 @@ describe("setEmailIntake", () => {
const before = ws.listEmailIntakes().find((i) => i.listId === support.id);
const after = ws.setEmailIntake(
support.id,
- { alias: "help", allowedSenders: ["sam@clinic.com"] },
+ { alias: "help", allowedSenders: ["sam@clinic.example"] },
OWNER
);
- expect(after).toMatchObject({ alias: "help", allowedSenders: ["sam@clinic.com"] });
+ expect(after).toMatchObject({ alias: "help", allowedSenders: ["sam@clinic.example"] });
expect(after?.createdAt).toBe(before?.createdAt);
expect(ws.listEmailIntakes().filter((i) => i.listId === support.id)).toHaveLength(1);
expect(
- ws.checkEmailIntake({ alias: "support", sender: "sam@clinic.com", messageId: "x@y" })
+ ws.checkEmailIntake({ alias: "support", sender: "sam@clinic.example", messageId: "x@y" })
).toEqual({ status: "unknown_alias" });
});
@@ -198,7 +198,7 @@ describe("setEmailIntake", () => {
it("is refused for a member, even though the route also gates it", async () => {
const { ws, plain } = await seeded();
expect(() =>
- ws.setEmailIntake(plain.id, { alias: "front", allowedSenders: ["@clinic.com"] }, MEMBER)
+ ws.setEmailIntake(plain.id, { alias: "front", allowedSenders: ["@clinic.example"] }, MEMBER)
).toThrow(/requires the owner or admin role/);
expect(() => ws.setEmailIntake(plain.id, null, MEMBER)).toThrow(/owner or admin/);
expect(ws.listEmailIntakes()).toHaveLength(2);
@@ -208,7 +208,7 @@ describe("setEmailIntake", () => {
const { ws, plain, support } = await seeded();
let message = "";
try {
- ws.setEmailIntake(plain.id, { alias: "Support", allowedSenders: ["@clinic.com"] }, OWNER);
+ ws.setEmailIntake(plain.id, { alias: "Support", allowedSenders: ["@clinic.example"] }, OWNER);
} catch (err) {
message = (err as Error).message;
}
@@ -220,14 +220,14 @@ describe("setEmailIntake", () => {
it("throws the list not-found error for an unknown list", async () => {
const { ws } = await seeded();
expect(() =>
- ws.setEmailIntake("ls_missing", { alias: "x", allowedSenders: ["@clinic.com"] }, OWNER)
+ ws.setEmailIntake("ls_missing", { alias: "x", allowedSenders: ["@clinic.example"] }, OWNER)
).toThrow(/List ls_missing not found/);
});
it("re-validates input the route would have rejected", async () => {
const { ws, plain } = await seeded();
expect(() =>
- ws.setEmailIntake(plain.id, { alias: "-bad-", allowedSenders: ["@clinic.com"] }, OWNER)
+ ws.setEmailIntake(plain.id, { alias: "-bad-", allowedSenders: ["@clinic.example"] }, OWNER)
).toThrow(/Alias must be/);
expect(() => ws.setEmailIntake(plain.id, { alias: "ok", allowedSenders: [] }, OWNER)).toThrow(
/at least one allowed sender/
@@ -239,20 +239,20 @@ describe("checkEmailIntake", () => {
it("ok carries the intake's list, matching case- and bracket-insensitively", async () => {
const { ws, support } = await seeded();
expect(
- ws.checkEmailIntake({ alias: "SUPPORT", sender: "Pat@Clinic.com", messageId: "" })
+ ws.checkEmailIntake({ alias: "SUPPORT", sender: "Pat@Clinic.example", messageId: "" })
).toEqual({ status: "ok", listId: support.id });
});
it("unknown_alias for an alias no list has", async () => {
const { ws } = await seeded();
- expect(ws.checkEmailIntake({ alias: "nobody", sender: "pat@clinic.com", messageId: "a@b" }))
+ expect(ws.checkEmailIntake({ alias: "nobody", sender: "pat@clinic.example", messageId: "a@b" }))
.toEqual({ status: "unknown_alias" });
});
it("unknown_alias when the intake's list is archived", async () => {
const { ws, support } = await seeded();
ws.updateList({ listId: support.id, archived: true }, OWNER);
- expect(ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.com", messageId: "a@b" }))
+ expect(ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.example", messageId: "a@b" }))
.toEqual({ status: "unknown_alias" });
});
@@ -264,36 +264,36 @@ describe("checkEmailIntake", () => {
it("duplicate names the task an already-ingested message produced", async () => {
const { ws, mail } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "dup@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "dup@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
expect(
- ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.com", messageId: "" })
+ ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.example", messageId: "" })
).toEqual({ status: "duplicate", taskId: first.taskId });
});
it("rate_limited once the sender has filed the hourly limit", async () => {
const { ws, mail } = await seeded();
for (let i = 0; i < EMAIL_INTAKE_HOURLY_LIMIT; i++) ws.ingestEmail(mail(), MAIL);
- expect(ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.com", messageId: "new@x" }))
+ expect(ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.example", messageId: "new@x" }))
.toEqual({ status: "rate_limited" });
});
it("duplicate is per list: the same Message-ID is new to another intake", async () => {
const { ws, billing, mail } = await seeded();
- ws.ingestEmail(mail({ messageId: "both@clinic.com" }), MAIL);
+ ws.ingestEmail(mail({ messageId: "both@clinic.example" }), MAIL);
expect(
- ws.checkEmailIntake({ alias: "billing", sender: "pat@clinic.com", messageId: "both@clinic.com" })
+ ws.checkEmailIntake({ alias: "billing", sender: "pat@clinic.example", messageId: "both@clinic.example" })
).toEqual({ status: "ok", listId: billing.id });
});
it("rate_limited once the list has received its hourly limit from any mix of senders", async () => {
const { ws, billing, mail } = await seeded();
for (let i = 0; i < EMAIL_INTAKE_LIST_HOURLY_LIMIT; i++) {
- ws.ingestEmail(mail({ sender: `s${i}@clinic.com` }), MAIL);
+ ws.ingestEmail(mail({ sender: `s${i}@clinic.example` }), MAIL);
}
- expect(ws.checkEmailIntake({ alias: "support", sender: "fresh@clinic.com", messageId: "new@x" }))
+ expect(ws.checkEmailIntake({ alias: "support", sender: "fresh@clinic.example", messageId: "new@x" }))
.toEqual({ status: "rate_limited" });
- expect(ws.checkEmailIntake({ alias: "billing", sender: "fresh@clinic.com", messageId: "new@x" }))
+ expect(ws.checkEmailIntake({ alias: "billing", sender: "fresh@clinic.example", messageId: "new@x" }))
.toEqual({ status: "ok", listId: billing.id });
});
@@ -301,7 +301,7 @@ describe("checkEmailIntake", () => {
const { ws, count, auditActions } = await seeded();
const audits = auditActions().length;
const changes = count("changes");
- ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.com", messageId: "a@b" });
+ ws.checkEmailIntake({ alias: "support", sender: "pat@clinic.example", messageId: "a@b" });
expect(auditActions()).toHaveLength(audits);
expect(count("changes")).toBe(changes);
});
@@ -312,8 +312,8 @@ describe("ingestEmail", () => {
const { ws, db, frames, support, mail, auditActions } = await seeded();
const result = ws.ingestEmail(
mail({
- messageId: "",
- task: { title: "Printer jammed", description: "From: pat@clinic.com\n\nHelp" },
+ messageId: "",
+ task: { title: "Printer jammed", description: "From: pat@clinic.example\n\nHelp" },
attachments: [
{ id: "at_email_1", r2Key: "attachments/at_email_1/photo.png", filename: "photo.png", size: 10, mimeType: "image/png" },
],
@@ -327,7 +327,7 @@ describe("ingestEmail", () => {
expect(detail.task).toMatchObject({
listId: support.id,
title: "Printer jammed",
- description: "From: pat@clinic.com\n\nHelp",
+ description: "From: pat@clinic.example\n\nHelp",
tags: ["email"],
assigneeId: null,
closedAt: null,
@@ -340,12 +340,12 @@ describe("ingestEmail", () => {
expect(db.prepare("SELECT * FROM email_messages").all()).toEqual([
expect.objectContaining({
- message_id: "first@clinic.com",
+ message_id: "first@clinic.example",
list_id: support.id,
alias: "support",
task_id: result.taskId,
comment_id: null,
- sender: "pat@clinic.com",
+ sender: "pat@clinic.example",
}),
]);
expect(auditActions()).toEqual(
@@ -385,7 +385,7 @@ describe("ingestEmail", () => {
it("threads a reply into a comment on the earlier ticket without reopening it", async () => {
const { ws, db, mail } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "orig@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "orig@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
ws.updateTask({ taskId: first.taskId, status: "Done" }, OWNER);
const closed = ws.getTaskDetail(first.taskId).task;
@@ -393,9 +393,9 @@ describe("ingestEmail", () => {
const reply = ws.ingestEmail(
mail({
- messageId: "reply@clinic.com",
+ messageId: "reply@clinic.example",
// In-Reply-To/References order: an unknown id first, then the original.
- referenceIds: ["someone-else@gmail.com", ""],
+ referenceIds: ["someone-else@gmail.com", ""],
commentBody: "Still broken",
attachments: [{ id: "at_reply", r2Key: "k/at_reply", filename: "b.txt", size: 2, mimeType: "text/plain" }],
}),
@@ -412,20 +412,20 @@ describe("ingestEmail", () => {
]);
expect(ws.listAttachments(first.taskId).map((a) => a.id)).toEqual(["at_reply"]);
expect(
- db.prepare("SELECT comment_id FROM email_messages WHERE message_id = 'reply@clinic.com'").all()
+ db.prepare("SELECT comment_id FROM email_messages WHERE message_id = 'reply@clinic.example'").all()
).toEqual([{ comment_id: reply.commentId }]);
// The reply itself can be replied to: threading follows the reply's own id too.
- const third = ws.ingestEmail(mail({ referenceIds: ["reply@clinic.com"] }), MAIL);
+ const third = ws.ingestEmail(mail({ referenceIds: ["reply@clinic.example"] }), MAIL);
expect(third).toMatchObject({ status: "commented", taskId: first.taskId });
});
it("never threads across aliases: a reply sent to another intake opens a ticket there", async () => {
const { ws, billing, mail } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "orig@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "orig@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
const crossed = ws.ingestEmail(
- mail({ alias: "billing", referenceIds: ["orig@clinic.com"] }),
+ mail({ alias: "billing", referenceIds: ["orig@clinic.example"] }),
MAIL
);
expect(crossed).toMatchObject({ status: "created", listId: billing.id });
@@ -436,11 +436,11 @@ describe("ingestEmail", () => {
it("falls back to a new ticket when the referenced task was deleted", async () => {
const { ws, support, mail } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "orig@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "orig@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
ws.deleteTask(first.taskId, OWNER);
- const reply = ws.ingestEmail(mail({ referenceIds: ["orig@clinic.com"] }), MAIL);
+ const reply = ws.ingestEmail(mail({ referenceIds: ["orig@clinic.example"] }), MAIL);
expect(reply).toMatchObject({ status: "created", listId: support.id });
if (reply.status !== "created") throw new Error("unreachable");
expect(reply.taskId).not.toBe(first.taskId);
@@ -455,7 +455,7 @@ describe("ingestEmail", () => {
expect(ws.ingestEmail(mail(), MAIL)).toEqual({ status: "rate_limited" });
expect(count("tasks")).toBe(tasks);
// Another sender on the same domain has their own budget.
- expect(ws.ingestEmail(mail({ sender: "sam@clinic.com" }), MAIL).status).toBe("created");
+ expect(ws.ingestEmail(mail({ sender: "sam@clinic.example" }), MAIL).status).toBe("created");
// Once the earlier messages age out of the window, pat may write again.
db.prepare("UPDATE email_messages SET received_at = received_at - 3600001").run();
@@ -481,78 +481,78 @@ describe("ingestEmail", () => {
it("one Message-ID sent to two intakes files once in each, and redelivery to either is a duplicate", async () => {
const { ws, db, support, billing, mail, count } = await seeded();
- const toSupport = ws.ingestEmail(mail({ messageId: "" }), MAIL);
- const toBilling = ws.ingestEmail(mail({ alias: "billing", messageId: "both@clinic.com" }), MAIL);
+ const toSupport = ws.ingestEmail(mail({ messageId: "" }), MAIL);
+ const toBilling = ws.ingestEmail(mail({ alias: "billing", messageId: "both@clinic.example" }), MAIL);
expect(toSupport).toMatchObject({ status: "created", listId: support.id });
expect(toBilling).toMatchObject({ status: "created", listId: billing.id });
if (toSupport.status !== "created" || toBilling.status !== "created") throw new Error("unreachable");
expect(toBilling.taskId).not.toBe(toSupport.taskId);
- expect(ws.ingestEmail(mail({ messageId: "both@clinic.com" }), MAIL)).toEqual({
+ expect(ws.ingestEmail(mail({ messageId: "both@clinic.example" }), MAIL)).toEqual({
status: "duplicate",
taskId: toSupport.taskId,
});
- expect(ws.ingestEmail(mail({ alias: "billing", messageId: "both@clinic.com" }), MAIL)).toEqual({
+ expect(ws.ingestEmail(mail({ alias: "billing", messageId: "both@clinic.example" }), MAIL)).toEqual({
status: "duplicate",
taskId: toBilling.taskId,
});
expect(count("tasks")).toBe(2);
const rows = db
- .prepare("SELECT list_id FROM email_messages WHERE message_id = 'both@clinic.com'")
+ .prepare("SELECT list_id FROM email_messages WHERE message_id = 'both@clinic.example'")
.all() as Array<{ list_id: string }>;
expect(rows.map((r) => r.list_id).sort()).toEqual([support.id, billing.id].sort());
});
it("threads by list, not alias: a reassigned alias never reaches the old list's tickets", async () => {
const { ws, support, plain, mail } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "orig@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "orig@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
// The alias moves to another client's list.
ws.setEmailIntake(support.id, null, OWNER);
- ws.setEmailIntake(plain.id, { alias: "support", allowedSenders: ["@clinic.com"] }, OWNER);
- const reassigned = ws.ingestEmail(mail({ referenceIds: ["orig@clinic.com"] }), MAIL);
+ ws.setEmailIntake(plain.id, { alias: "support", allowedSenders: ["@clinic.example"] }, OWNER);
+ const reassigned = ws.ingestEmail(mail({ referenceIds: ["orig@clinic.example"] }), MAIL);
expect(reassigned).toMatchObject({ status: "created", listId: plain.id });
expect(ws.listComments(first.taskId)).toEqual([]);
// The original list, under a new alias, still threads its own tickets.
- ws.setEmailIntake(support.id, { alias: "help", allowedSenders: ["@clinic.com"] }, OWNER);
- const renamed = ws.ingestEmail(mail({ alias: "help", referenceIds: ["orig@clinic.com"] }), MAIL);
+ ws.setEmailIntake(support.id, { alias: "help", allowedSenders: ["@clinic.example"] }, OWNER);
+ const renamed = ws.ingestEmail(mail({ alias: "help", referenceIds: ["orig@clinic.example"] }), MAIL);
expect(renamed).toMatchObject({ status: "commented", taskId: first.taskId, listId: support.id });
});
it("caps a list at its hourly limit across senders, independently of other lists", async () => {
const { ws, db, mail, count } = await seeded();
for (let i = 0; i < EMAIL_INTAKE_LIST_HOURLY_LIMIT; i++) {
- expect(ws.ingestEmail(mail({ sender: `s${i}@clinic.com` }), MAIL).status).toBe("created");
+ expect(ws.ingestEmail(mail({ sender: `s${i}@clinic.example` }), MAIL).status).toBe("created");
}
const tasks = count("tasks");
- expect(ws.ingestEmail(mail({ sender: "s-new@clinic.com" }), MAIL)).toEqual({ status: "rate_limited" });
+ expect(ws.ingestEmail(mail({ sender: "s-new@clinic.example" }), MAIL)).toEqual({ status: "rate_limited" });
expect(count("tasks")).toBe(tasks);
- expect(ws.ingestEmail(mail({ alias: "billing", sender: "s-new@clinic.com" }), MAIL).status).toBe("created");
+ expect(ws.ingestEmail(mail({ alias: "billing", sender: "s-new@clinic.example" }), MAIL).status).toBe("created");
db.prepare("UPDATE email_messages SET received_at = received_at - 3600001").run();
- expect(ws.ingestEmail(mail({ sender: "s-new@clinic.com" }), MAIL).status).toBe("created");
+ expect(ws.ingestEmail(mail({ sender: "s-new@clinic.example" }), MAIL).status).toBe("created");
});
it("tags a new task from an unauthenticated sender 'unverified', and still threads its replies", async () => {
const { ws, db, mail } = await seeded();
- const opened = ws.ingestEmail(mail({ messageId: "spoof@clinic.com", authenticated: false }), MAIL);
+ const opened = ws.ingestEmail(mail({ messageId: "spoof@clinic.example", authenticated: false }), MAIL);
if (opened.status !== "created") throw new Error(`expected created, got ${opened.status}`);
expect(ws.getTaskDetail(opened.taskId).task.tags).toEqual(["email", "unverified"]);
- const verified = ws.ingestEmail(mail({ messageId: "real@clinic.com" }), MAIL);
+ const verified = ws.ingestEmail(mail({ messageId: "real@clinic.example" }), MAIL);
if (verified.status !== "created") throw new Error(`expected created, got ${verified.status}`);
expect(ws.getTaskDetail(verified.taskId).task.tags).toEqual(["email"]);
// An unauthenticated reply lands as a comment and leaves the tags alone.
const reply = ws.ingestEmail(
- mail({ authenticated: false, referenceIds: ["spoof@clinic.com"] }),
+ mail({ authenticated: false, referenceIds: ["spoof@clinic.example"] }),
MAIL
);
expect(reply).toMatchObject({ status: "commented", taskId: opened.taskId });
const replyToVerified = ws.ingestEmail(
- mail({ authenticated: false, referenceIds: ["real@clinic.com"] }),
+ mail({ authenticated: false, referenceIds: ["real@clinic.example"] }),
MAIL
);
expect(replyToVerified).toMatchObject({ status: "commented", taskId: verified.taskId });
@@ -566,14 +566,14 @@ describe("ingestEmail", () => {
it("looks at only the first 20 reference ids", async () => {
const { ws, support, mail } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "orig@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "orig@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
const noise = (k: number) => Array.from({ length: k }, (_, i) => `noise${i}@elsewhere.com`);
- const twentyFirst = ws.ingestEmail(mail({ referenceIds: [...noise(20), "orig@clinic.com"] }), MAIL);
+ const twentyFirst = ws.ingestEmail(mail({ referenceIds: [...noise(20), "orig@clinic.example"] }), MAIL);
expect(twentyFirst).toMatchObject({ status: "created", listId: support.id });
- const twentieth = ws.ingestEmail(mail({ referenceIds: [...noise(19), "orig@clinic.com"] }), MAIL);
+ const twentieth = ws.ingestEmail(mail({ referenceIds: [...noise(19), "orig@clinic.example"] }), MAIL);
expect(twentieth).toMatchObject({ status: "commented", taskId: first.taskId });
});
@@ -591,11 +591,11 @@ describe("ingestEmail", () => {
it("throws a permanent error when the inbound actor can't write to the threaded task", async () => {
const { ws, space, mail, count } = await seeded();
- const first = ws.ingestEmail(mail({ messageId: "orig@clinic.com" }), MAIL);
+ const first = ws.ingestEmail(mail({ messageId: "orig@clinic.example" }), MAIL);
if (first.status !== "created") throw new Error(`expected created, got ${first.status}`);
ws.setSpaceVisibility({ spaceId: space.id, visibility: "private" }, OWNER);
const changes = count("changes");
- expect(() => ws.ingestEmail(mail({ referenceIds: ["orig@clinic.com"] }), MEMBER_MAIL)).toThrow(
+ expect(() => ws.ingestEmail(mail({ referenceIds: ["orig@clinic.example"] }), MEMBER_MAIL)).toThrow(
new RegExp(`^Email intake permanent failure: the inbound actor may not write to task ${first.taskId}\\.`)
);
expect(count("comments")).toBe(0);
@@ -629,11 +629,11 @@ describe("ingestEmail", () => {
describe("deleteList", () => {
it("removes the list's intake in the same turn, freeing the alias", async () => {
const { ws, plain } = await seeded();
- ws.setEmailIntake(plain.id, { alias: "front", allowedSenders: ["@clinic.com"] }, OWNER);
+ ws.setEmailIntake(plain.id, { alias: "front", allowedSenders: ["@clinic.example"] }, OWNER);
ws.deleteList(plain.id, OWNER);
expect(ws.listEmailIntakes().map((i) => i.alias)).toEqual(["billing", "support"]);
- expect(ws.checkEmailIntake({ alias: "front", sender: "pat@clinic.com", messageId: "a@b" }))
+ expect(ws.checkEmailIntake({ alias: "front", sender: "pat@clinic.example", messageId: "a@b" }))
.toEqual({ status: "unknown_alias" });
});
});
diff --git a/packages/core/src/email-intake.test.ts b/packages/core/src/email-intake.test.ts
index 623c54a..7e5cd36 100644
--- a/packages/core/src/email-intake.test.ts
+++ b/packages/core/src/email-intake.test.ts
@@ -9,27 +9,27 @@ import { messageIdsIn, normalizeMessageId, senderAllowed } from "./email-intake.
describe("senderAllowed", () => {
it("an exact address admits that address only", () => {
- expect(senderAllowed("pat@clinic.com", ["pat@clinic.com"])).toBe(true);
- expect(senderAllowed("sam@clinic.com", ["pat@clinic.com"])).toBe(false);
+ expect(senderAllowed("pat@clinic.example", ["pat@clinic.example"])).toBe(true);
+ expect(senderAllowed("sam@clinic.example", ["pat@clinic.example"])).toBe(false);
});
it("an @domain entry admits the whole domain but not its subdomains", () => {
- expect(senderAllowed("anyone@clinic.com", ["@clinic.com"])).toBe(true);
- expect(senderAllowed("x@mail.clinic.com", ["@clinic.com"])).toBe(false);
- expect(senderAllowed("x@evilclinic.com", ["@clinic.com"])).toBe(false);
+ expect(senderAllowed("anyone@clinic.example", ["@clinic.example"])).toBe(true);
+ expect(senderAllowed("x@mail.clinic.example", ["@clinic.example"])).toBe(false);
+ expect(senderAllowed("x@evilclinic.example", ["@clinic.example"])).toBe(false);
});
it("compares both sides lowercased and trimmed", () => {
- expect(senderAllowed(" Pat@Clinic.COM ", ["PAT@clinic.com"])).toBe(true);
- expect(senderAllowed("x@CLINIC.com", [" @Clinic.com "])).toBe(true);
+ expect(senderAllowed(" Pat@Clinic.example ", ["PAT@clinic.example"])).toBe(true);
+ expect(senderAllowed("x@CLINIC.example", [" @Clinic.example "])).toBe(true);
});
it("refuses malformed senders and an empty allowlist", () => {
- expect(senderAllowed("clinic.com", ["@clinic.com"])).toBe(false);
+ expect(senderAllowed("clinic.example", ["@clinic.example"])).toBe(false);
// No local part: a domain entry must never match itself as a sender.
- expect(senderAllowed("@clinic.com", ["@clinic.com"])).toBe(false);
- expect(senderAllowed("pat@", ["@clinic.com"])).toBe(false);
- expect(senderAllowed("pat@clinic.com", [])).toBe(false);
+ expect(senderAllowed("@clinic.example", ["@clinic.example"])).toBe(false);
+ expect(senderAllowed("pat@", ["@clinic.example"])).toBe(false);
+ expect(senderAllowed("pat@clinic.example", [])).toBe(false);
});
});
diff --git a/packages/core/src/email-reply-storage.test.ts b/packages/core/src/email-reply-storage.test.ts
index eddeddb..1a19b26 100644
--- a/packages/core/src/email-reply-storage.test.ts
+++ b/packages/core/src/email-reply-storage.test.ts
@@ -100,7 +100,7 @@ async function seeded(env: { EMAIL_INTAKE_REPLY?: string } = { EMAIL_INTAKE_REPL
ws.upsertUser({ id: OWNER, email: "owner@example.com", name: "Owner", role: "owner" }, OWNER);
const space = ws.createSpace(CreateSpaceInput.parse({ name: "Ops", visibility: "workspace" }), OWNER);
const support = ws.createList(CreateListInput.parse({ spaceId: space.id, name: "Support" }), OWNER);
- ws.setEmailIntake(support.id, { alias: "support", allowedSenders: ["@clinic.com"] }, OWNER);
+ ws.setEmailIntake(support.id, { alias: "support", allowedSenders: ["@clinic.example"] }, OWNER);
const frames: ServerMsg[] = [];
sockets.push({
@@ -114,9 +114,9 @@ async function seeded(env: { EMAIL_INTAKE_REPLY?: string } = { EMAIL_INTAKE_REPL
n += 1;
return {
alias: "support",
- sender: "pat@clinic.com",
+ sender: "pat@clinic.example",
authenticated: true,
- messageId: `m${n}@clinic.com`,
+ messageId: `m${n}@clinic.example`,
referenceIds: [],
task: { title: `Ticket ${n}`, description: `Body ${n}` },
commentBody: `Reply ${n}`,
@@ -161,26 +161,26 @@ async function seeded(env: { EMAIL_INTAKE_REPLY?: string } = { EMAIL_INTAKE_REPL
describe("createComment with emailToRequester", () => {
it("records the recipient on the comment and queues the reply in the requester's thread", async () => {
const { ws, ticket, mail, replies, comment, auditActions } = await seeded();
- const taskId = ticket({ messageId: "first@clinic.com", task: { title: "Re: Printer jammed", description: "Help" } });
+ const taskId = ticket({ messageId: "first@clinic.example", task: { title: "Re: Printer jammed", description: "Help" } });
// A follow-up in the same thread, so the reply answers the newest message.
- ws.ingestEmail(mail({ messageId: "second@clinic.com", referenceIds: ["first@clinic.com"] }), MAIL);
+ ws.ingestEmail(mail({ messageId: "second@clinic.example", referenceIds: ["first@clinic.example"] }), MAIL);
const created = ws.createComment(comment(taskId, "Done, here it is: https://example.com/page", true), PERSON);
- expect(created).toMatchObject({ emailTo: "pat@clinic.com", emailStatus: "sending", via: "ui" });
+ expect(created).toMatchObject({ emailTo: "pat@clinic.example", emailStatus: "sending", via: "ui" });
expect(ws.getTaskDetail(taskId).comments.find((c) => c.id === created.id)).toMatchObject({
- emailTo: "pat@clinic.com",
+ emailTo: "pat@clinic.example",
emailStatus: "sending",
});
expect(replies()).toEqual([
{
kind: "email-reply",
- to: "pat@clinic.com",
+ to: "pat@clinic.example",
alias: "support",
subject: "Re: Printer jammed",
body: "Done, here it is: https://example.com/page",
- inReplyTo: "second@clinic.com",
- references: ["first@clinic.com", "second@clinic.com"],
+ inReplyTo: "second@clinic.example",
+ references: ["first@clinic.example", "second@clinic.example"],
taskId,
commentId: created.id,
},
@@ -210,8 +210,8 @@ describe("createComment with emailToRequester", () => {
const taskId = ticket();
const allowed = key(true);
- const sent = ws.createComment(comment(taskId, "Done, from the CLI", true), viaKey(allowed.id));
- expect(sent).toMatchObject({ emailTo: "pat@clinic.com", emailStatus: "sending", via: "api" });
+ const sent = ws.createComment(comment(taskId, "Done, from an API client", true), viaKey(allowed.id));
+ expect(sent).toMatchObject({ emailTo: "pat@clinic.example", emailStatus: "sending", via: "api" });
expect(replies()).toHaveLength(1);
expect(() => ws.createComment(comment(taskId, "Done", true), viaKey(allowed.id, "mcp"))).toThrow(
@@ -245,10 +245,10 @@ describe("createComment with emailToRequester", () => {
it("answers an unverified sender too, at the address the ticket came from", async () => {
const { ws, ticket, replies, comment } = await seeded();
- const taskId = ticket({ authenticated: false, sender: "dana@clinic.com" });
+ const taskId = ticket({ authenticated: false, sender: "dana@clinic.example" });
const created = ws.createComment(comment(taskId, "Done", true), PERSON);
- expect(created).toMatchObject({ emailTo: "dana@clinic.com", emailStatus: "sending" });
- expect(replies()).toEqual([expect.objectContaining({ to: "dana@clinic.com", commentId: created.id })]);
+ expect(created).toMatchObject({ emailTo: "dana@clinic.example", emailStatus: "sending" });
+ expect(replies()).toEqual([expect.objectContaining({ to: "dana@clinic.example", commentId: created.id })]);
});
it("is refused for a task that never came in by email, and after its intake alias is removed", async () => {
@@ -267,11 +267,11 @@ describe("TaskDetail.emailReplyTo", () => {
it("names the requester, verified or not, and is null for a task that can't be answered", async () => {
const { ws, ticket, support } = await seeded();
expect(ws.getTaskDetail(ticket({ task: { title: "Fwd: Hours", description: "x" } }))).toMatchObject({
- emailReplyTo: "pat@clinic.com",
+ emailReplyTo: "pat@clinic.example",
emailReplySubject: "Re: Hours",
});
expect(ws.getTaskDetail(ticket({ authenticated: false, task: { title: "Hours", description: "x" } }))).toMatchObject({
- emailReplyTo: "pat@clinic.com",
+ emailReplyTo: "pat@clinic.example",
emailReplySubject: "Re: Hours",
});
const typed = ws.createTask(CreateTaskInput.parse({ listId: support.id, title: "Typed in" }), PERSON);
@@ -299,7 +299,7 @@ describe("markEmailReply", () => {
entity: "comment",
op: "update",
id: created.id,
- data: expect.objectContaining({ taskId, emailStatus: "sent", emailTo: "pat@clinic.com" }),
+ data: expect.objectContaining({ taskId, emailStatus: "sent", emailTo: "pat@clinic.example" }),
}),
]);
expect(auditActions()).toContain("comment.email_status");
@@ -338,7 +338,7 @@ describe("setApiKeyEmailRequesters", () => {
expect(auditActions().filter((a) => a === "api_key.email_requesters")).toHaveLength(2);
});
- it("works from an owner's api key (the CLI), but never over MCP", async () => {
+ it("works from an owner's api key, but never over MCP", async () => {
const { ws, key, viaKey } = await seeded();
const cli = key(false);
expect(ws.setApiKeyEmailRequesters(cli.id, true, viaKey(cli.id)).canEmailRequesters).toBe(true);
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 7532de0..8da0ab7 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -5269,7 +5269,7 @@ function backupTableNames(sql: SqlStorage): string[] {
.toArray();
// Leading-underscore names cover our _migrations bookkeeping AND the
// runtime's internal _cf_KV table, which sqlite_master exposes in prod but
- // SELECTing from is prohibited (this killed every backup until 2026-08-17).
+ // SELECTing from is prohibited (this used to kill every backup).
return rows.map((r) => r.name).filter((name) => !name.startsWith("_") && !name.startsWith("tasks_fts"));
}
diff --git a/packages/core/src/migrations.test.ts b/packages/core/src/migrations.test.ts
index 323a4e5..1370e23 100644
--- a/packages/core/src/migrations.test.ts
+++ b/packages/core/src/migrations.test.ts
@@ -391,7 +391,7 @@ describe("core-0014-email-replies", () => {
const message = (messageId: string, taskId: string) =>
sql.exec(
`INSERT INTO email_messages (message_id, list_id, alias, task_id, comment_id, sender, received_at)
- VALUES (?, 'ls_1', 'support', ?, NULL, 'pat@clinic.com', 0)`,
+ VALUES (?, 'ls_1', 'support', ?, NULL, 'pat@clinic.example', 0)`,
messageId,
taskId
);
@@ -400,7 +400,7 @@ describe("core-0014-email-replies", () => {
`INSERT INTO audit (actor, action, entity, diff, at, actor_user_id, api_key_id)
VALUES ('{}', 'email.ingest', ?, ?, 0, 'us_1', NULL)`,
taskId,
- JSON.stringify({ listId, alias: "support", sender: "pat@clinic.com", authenticated, messageId, commentId: null })
+ JSON.stringify({ listId, alias: "support", sender: "pat@clinic.example", authenticated, messageId, commentId: null })
);
message("verified@x", "tk_1");
ingestAudit("verified@x", "tk_1", true);
diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts
index 34aaa22..51ad987 100644
--- a/packages/core/src/schema.ts
+++ b/packages/core/src/schema.ts
@@ -444,7 +444,7 @@ const PENDING_OBJECT_DELETES: string[] = [
];
// Last outcome of a scheduled job, one row per job kind. The daily backup
-// failed silently for four days in August 2026 because its only trace was a
+// could fail silently for days because its only trace was a
// console line; the backup job now writes its row here on every run, and
// /api/health reads it to flag a stale backup. `last_success_detail` is JSON
// (object key, row count, bytes). IF NOT EXISTS so it is a no-op on an
@@ -522,7 +522,7 @@ const HANDOFFS: string[] = [
// delivery stands ("sending", then "sent" or "failed" from the queue consumer).
// NULL in both is an ordinary internal comment, which every earlier one is.
// `api_keys.can_email_requesters` lets an owner or admin allow one key (say,
-// the one a CLI uses) to send those replies too; every key starts without it.
+// the one an API client uses) to send those replies too; every key starts without it.
// `email_messages.authenticated` keeps whether Cloudflare vouched for each
// sender. Rows filed before it are backfilled from their email.ingest audit
// row, and any row that audit can't vouch for reads as unverified (0). Replies
From 8ac13002b9d5c41a403f21481022eac14d6208bd Mon Sep 17 00:00:00 2001
From: Joel Borchardt <121824267+joelborch@users.noreply.github.com>
Date: Tue, 20 Oct 2026 11:08:50 -0400
Subject: [PATCH 6/6] Strip reply prefixes one at a time instead of with a
nested quantifier
CodeQL flagged /^(\s*(re|fwd?|aw)\s*:\s*)+/ in replySubject as polynomial
on long runs of spaces, and the title came from the sender's subject.
Same results with a simple anchored match in a loop, plus tests.
Co-Authored-By: Claude Opus 5.5
---
packages/core/src/email-intake.test.ts | 19 ++++++++++++++++++-
packages/core/src/email-intake.ts | 14 +++++++++-----
2 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/packages/core/src/email-intake.test.ts b/packages/core/src/email-intake.test.ts
index 7e5cd36..90d0f77 100644
--- a/packages/core/src/email-intake.test.ts
+++ b/packages/core/src/email-intake.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { messageIdsIn, normalizeMessageId, senderAllowed } from "./email-intake.js";
+import { messageIdsIn, normalizeMessageId, replySubject, senderAllowed } from "./email-intake.js";
// ---------------------------------------------------------------------------
// The pure halves of email intake: who may write in, and how Message-ID /
@@ -70,3 +70,20 @@ describe("messageIdsIn", () => {
expect(messageIdsIn("no ids here")).toEqual([]);
});
});
+
+describe("replySubject", () => {
+ it("prefixes Re: once, dropping earlier reply and forward prefixes and line breaks", () => {
+ expect(replySubject("Printer jammed")).toBe("Re: Printer jammed");
+ expect(replySubject("RE: Fwd: re : Printer jammed")).toBe("Re: Printer jammed");
+ expect(replySubject("AW: Hours\r\nupdate")).toBe("Re: Hours update");
+ expect(replySubject("Re:")).toBe("Re: your request");
+ expect(replySubject("Regarding hours")).toBe("Re: Regarding hours");
+ });
+
+ it("stays fast on a long run of spaces", () => {
+ const started = performance.now();
+ expect(replySubject(`re:${" ".repeat(50_000)}x`)).toBe("Re: x");
+ expect(replySubject(`re ${" ".repeat(50_000)}x`).startsWith("Re: re")).toBe(true);
+ expect(performance.now() - started).toBeLessThan(200);
+ });
+});
diff --git a/packages/core/src/email-intake.ts b/packages/core/src/email-intake.ts
index 2586513..4008ea4 100644
--- a/packages/core/src/email-intake.ts
+++ b/packages/core/src/email-intake.ts
@@ -48,14 +48,18 @@ export const EMAIL_REPLY_REFERENCE_LIMIT = 20;
* do line breaks, which a header can't carry.
*/
export function replySubject(title: string): string {
- const base = title
- .replace(/[\r\n]+/g, " ")
- .replace(/^(\s*(re|fwd?|aw)\s*:\s*)+/i, "")
- .trim()
- .slice(0, 200);
+ let base = title.replace(/[\r\n]+/g, " ").trim();
+ // One prefix at a time: a single /^(\s*(re|fwd?|aw)\s*:\s*)+/ is
+ // polynomial on long runs of spaces, and the title came from a sender.
+ for (let m = REPLY_PREFIX.exec(base); m !== null; m = REPLY_PREFIX.exec(base)) {
+ base = base.slice(m[0].length).trimStart();
+ }
+ base = base.trim().slice(0, 200);
return base === "" ? "Re: your request" : `Re: ${base}`;
}
+const REPLY_PREFIX = /^(?:re|fwd?|aw)[ \t]*:/i;
+
/** An attachment the Worker has already written to R2. */
export interface EmailIngestAttachment {
id: string;