Skip to content

Reply in the thread instead of starting a new one - #6

Merged
rutgerhofste merged 1 commit into
mainfrom
claude/mail-threading-support-8svkof
Jul 29, 2026
Merged

Reply in the thread instead of starting a new one#6
rutgerhofste merged 1 commit into
mainfrom
claude/mail-threading-support-8svkof

Conversation

@rutgerhofste

Copy link
Copy Markdown
Member

What was wrong

mail_send and mail_draft had no way to say "this answers that message". Every reply left as a fresh conversation carrying nothing but a Re: subject. Gmail usually guessed it back into the thread from the participants; Outlook generally did not — and the tool's own answer gave no way to tell which had happened.

What this does

Both tools take reply_to_uid (+ reply_to_folder, default INBOX).

Threading is the provider's job. The uid travels down to the backend untouched, because how you join a thread is the transport's business:

  • IMAP/SMTP reads the parent's Message-ID and References (imap.reply_headers, headers-only fetch) and sends the matching In-Reply-To / References — joined in provider.py, the only thing that sees both halves.
  • An API backend may have a conversation of its own to join. The private admin package's Graph provider uses createReply, so Outlook's conversationId stays Outlook's to assign.

The tool layer derives only what a person would see: the recipient (Reply-To when the sender set one, else From), the Re: subject without stacking a prefix per hop, and reply-all's cc list minus yourself. So to and subject may be omitted on a reply — and are still required without one. SendResult.in_reply_to names the message that was answered, so a client can report "sent as a reply" honestly.

Two quieter halves:

  • Editing a reply draft keeps its threading. update_draft rewrites the message, so a reviewed reply would otherwise turn back into a new conversation at the moment it is sent.
  • A long References is trimmed to the thread root plus the nearest ancestors (MAX_REFERENCES), the way every mail client does it.

MessageDetail/MailBody gain reply_to_addrs (defaulted, so a backend written before this still constructs).

Compatibility

to, subject and body move from schema-required to tool-validated on mail_send/mail_draft, since they are genuinely optional when replying. A call that omits one without a reply_to_uid gets the same refusal it always did, now with a clearer message.

Testing

  • tests/test_reply_threading.py — the headers, the chain trim, the subject/address derivation, the tools through FastMCP, and the IMAP↔SMTP hand-off in SoverinMailProvider.
  • tests/test_imap_threading.pyreply_headers against a fake mailbox (chain building, the In-Reply-To fallback, a parent with no Message-ID replying unthreaded rather than failing, headers-only), and update_draft carrying threading over.
  • GreenMail e2e — sends a real reply and reads the delivered headers back off the server; and drafts a reply, edits it, and checks the thread survived. Only a round trip proves the headers survived the append, the submission and the delivery.
  • make test and make lint green locally; the integration and docker/browser jobs run in CI (no Docker in this environment).

Generated by Claude Code

mail_send and mail_draft had no way to say "this answers that message", so
every reply left as a fresh conversation carrying nothing but a "Re:" subject.
Gmail usually guessed it back into the thread on the participants; Outlook
generally did not, and the tool's own answer gave no way to tell which had
happened.

Both now take reply_to_uid (+ reply_to_folder). The threading itself is the
provider's job -- the uid travels down untouched, and the IMAP/SMTP backend
reads the parent's Message-ID and References and sends the matching headers.
That leaves room for a backend that threads differently: the private Graph
provider joins Outlook's own conversation rather than writing headers.

What the tool layer derives is only what a person would see: the recipient
(Reply-To when the sender set one, else From), the "Re:" subject without
stacking a prefix per hop, and reply-all's cc list minus yourself. So to and
subject may be omitted on a reply -- and are still required without one.

Two quieter halves: editing a reply draft keeps its threading (the edit
rewrites the message, so a reviewed reply would otherwise turn back into a new
conversation at the moment it is sent), and a long References is trimmed to
the thread root plus the nearest ancestors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EodnnscF7qqhJrnDVqJSoK
@rutgerhofste
rutgerhofste marked this pull request as ready for review July 29, 2026 08:08
@rutgerhofste
rutgerhofste merged commit b35d32a into main Jul 29, 2026
3 checks passed
@rutgerhofste
rutgerhofste deleted the claude/mail-threading-support-8svkof branch August 13, 2026 14:29
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.

1 participant