Skip to content

Catch-up 4/4: email replies, docs, fixture cleanup - #55

Merged
joelborch merged 6 commits into
mainfrom
port/email-replies-docs
Oct 20, 2026
Merged

joelborch merged 6 commits into
mainfrom
port/email-replies-docs

Conversation

@joelborch

Copy link
Copy Markdown
Owner

Last of four stacked PRs (on top of #54): email replies, the docs for everything in this catch-up, and a fixture cleanup.

Email replies (migration core-0014-email-replies)

On a task opened by email intake, a comment posted with "emailToRequester": true also goes to the sender whose email opened the ticket. The comment is the email, so the task records exactly what was sent.

  • Sent from EMAIL_FROM, as Re: <title>, threaded on the sender's messages, with Reply-To on the intake address so their answer lands back on the task.
  • Sent through the side-effects queue. The consumer marks the comment sent after the send, and a dead-lettered send marks it failed. A failed status report never retries into a second email.
  • Who can send: a person signed in to the app, or an API key an owner or admin allowed with PATCH /api/api-keys/:id ({"canEmailRequesters": true}). MCP never can, and flow_comment_on_task doesn't offer the option.
  • Gated by EMAIL_INTAKE_REPLY (off in wrangler.example.jsonc), and fail-closed on EMAIL_DRY_RUN like every other send.
  • Unverified senders can be answered too: the address is always on the intake's allowlist, and the reply goes to that address, so someone forging it never receives the reply.
  • GET /api/tasks/:id adds emailReplyTo and emailReplySubject, so a client can preview the exact email before sending.
  • The web app shows "Emailed to …" (or "failed") under emailed comments. Sending is API-only, and the comment box is unchanged.

Docs

  • API.md: handoffs, email intake, backups, emailToRequester, the API-key PATCH, subtask notes, and backup staleness on /api/health.
  • AGENTS_AND_MCP.md: the three handoff tools and their trust model.
  • AUTOMATIONS.md: set_due_date.
  • SELF_HOSTING.md: step-by-step email intake setup (a dedicated domain, the catch-all rule, checking the auth header before turning on acknowledgements, and replies), plus the new backup and retention behaviour.
  • The package READMEs and the root README's feature list are updated to match.

Cleanup

Test fixtures now use reserved domains (clinic.example, lab.example) instead of real registered ones, a real social post in a test is now a placeholder, and a few comments lose details that only made sense for one deployment. No behaviour changes.

Checks

🤖 Generated with Claude Code

joelborch and others added 5 commits October 20, 2026 10:45
…ester

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: <title>" 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@joelborch
joelborch changed the base branch from port/intake-handoffs to main October 20, 2026 15:06
@joelborch joelborch closed this Oct 20, 2026
@joelborch joelborch reopened this Oct 20, 2026
Comment thread packages/core/src/email-intake.ts Fixed
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 <noreply@anthropic.com>
@joelborch
joelborch merged commit a8a76a6 into main Oct 20, 2026
5 checks passed
@joelborch
joelborch deleted the port/email-replies-docs branch October 20, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants