Skip to content

Keep a copy of what was sent, where the sender looks for it - #11

Merged
rutgerhofste merged 1 commit into
mainfrom
claude/mail-send-sent-folder-imap-yzga3z
Aug 13, 2026
Merged

rutgerhofste merged 1 commit into
mainfrom
claude/mail-send-sent-folder-imap-yzga3z

Conversation

@rutgerhofste

Copy link
Copy Markdown
Member

The bug

mail_send from a Soverin mailbox reported Sent with a real message id, the
recipient got the mail — and mail_search on Sent found nothing. It never
had. SMTP delivers a message and keeps nothing, and most IMAP hosts (Soverin
included) file no copy server-side either, so the sender's own record of the
message did not exist. A send you cannot find afterwards reads as a send that
never happened.

The fix

provider.send finishes the job. smtp.send hands back the exact RFC822
bytes
it delivered plus the moment it did, and provider.py — the one place
that sees both halves, exactly as with threading — APPENDs them to the Sent
folder \Seen, with the send time as INTERNALDATE so the copy sorts where it
belongs. Rebuilding the message instead would mint a fresh Message-ID and Date,
and the copy would no longer be the mail the recipient got.

Four decisions hold it up:

  • The folder is read, not guessed. The \Sent SPECIAL-USE attribute
    (RFC 6154) off the same LIST mail_list_folders already returns. The name is
    localized (Verzonden items), sometimes under INBOX, and Sent Items /
    Sent Messages are both common — Sent is only the fallback when a server
    advertises nothing. Resolved once per session and cached; a failed listing
    is deliberately not cached.
  • A failed APPEND does not fail the send. The message is with the recipient
    by then, so raising would report a delivered mail as undelivered and invite a
    retry that sends it twice. The outcome rides back as saved_to_sent /
    sent_folder on SendResult, and the tool docstring tells the client to say
    so and not resend.
  • The Bcc header stays on the copy and only comes off what leaves: your own
    Sent folder is the only record of whom you blind-copied.
  • A host that files its own copy gets one, not two — the Message-ID is
    searched for in the folder before the APPEND.

A reply travels this same path, so it is filed like any other message,
threading headers and all.

Tests

  • tests/test_sent_copy.py (new): SPECIAL-USE discovery incl. a localized
    name, the Sent fallback, resolve-once, a failed listing not being cached,
    the APPEND's bytes/flags/INTERNALDATE, the no-duplicate probe, the provider
    joining SMTP and IMAP, a reply being filed, an APPEND failure being reported
    rather than raised, and Bcc on the copy but not on the wire.
  • tests/test_tools.py: mail_send reports the copy; a failed copy is a
    successful send with saved_to_sent=False.
  • GreenMail e2e: sends for real and reads the copy back out of Sent, for a
    new message and for a reply. (Not run here — no Docker in this environment;
    make test is green: 221 passed. Ruff + ty clean.)

Companion change

pantalytics/squirrel-mcp-admin#… — the Graph backend reports
saved_to_sent=True / Sent Items instead of filing anything, because
/send already leaves the message in Sent Items server-side. That report is
what keeps a second copy from being filed on top of Graph's. Shipping the fix
to the hosted product also needs ARG SQUIRREL_MCP_REF in the admin
Dockerfile bumped to this commit once merged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPDJU6WSgJ7cQdfp8GoHXC


Generated by Claude Code

SMTP delivers a message and keeps nothing, and most IMAP hosts -- Soverin
among them -- file no copy server-side either. So a mail sent through
Squirrel really was gone the moment it left: with the recipient, and absent
from the sender's own Sent folder, which is where mail_search and every mail
client look. A send you cannot find afterwards reads as a send that never
happened.

So provider.send finishes the job. smtp.send hands back the exact RFC822
bytes it delivered plus the moment it did, and provider.py -- the one place
that sees both halves, exactly as with threading -- APPENDs them to the Sent
folder \Seen, with the send time as INTERNALDATE so the copy sorts where it
belongs. Rebuilding the message instead would mint a fresh Message-ID and
Date, and the copy would no longer be the mail the recipient got.

Four decisions hold it up:

* The folder is read, not guessed: the \Sent SPECIAL-USE attribute (RFC 6154)
  off the same LIST mail_list_folders already returns. The name is localized
  ("Verzonden items"), sometimes under INBOX, and "Sent Items"/"Sent Messages"
  are both common -- "Sent" is only the fallback when a server advertises
  nothing.
* A failed APPEND does not fail the send. The message is with the recipient by
  then, so raising would report a delivered mail as undelivered and invite a
  retry that sends it twice. The outcome rides back as saved_to_sent /
  sent_folder on SendResult, and the tool tells the user.
* The Bcc header stays on the copy and only comes off what leaves: your own
  Sent folder is the only record of whom you blind-copied.
* A host that files its own copy gets one, not two -- the Message-ID is
  searched for in the folder before the APPEND. Same reason the Graph backend
  in the admin package reports saved_to_sent=True rather than filing anything:
  /send puts the message in Sent Items server-side.

A reply travels this same path, so it is filed like any other message,
threading headers and all.

tests/test_sent_copy.py pins the discovery, the flags, the deduplication and
the never-fatal rule; the GreenMail e2e sends for real and reads the copy back
out of Sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPDJU6WSgJ7cQdfp8GoHXC
@rutgerhofste
rutgerhofste marked this pull request as ready for review August 13, 2026 11:40
@rutgerhofste
rutgerhofste merged commit 408885d into main Aug 13, 2026
3 checks passed
@rutgerhofste
rutgerhofste deleted the claude/mail-send-sent-folder-imap-yzga3z 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.

2 participants