Skip to content

Mark mail read or unread (mail_mark_read) - #14

Merged
rutgerhofste merged 1 commit into
mainfrom
claude/upbeat-cori-8cf9kh
Sep 16, 2026
Merged

rutgerhofste merged 1 commit into
mainfrom
claude/upbeat-cori-8cf9kh

Conversation

@rutgerhofste

Copy link
Copy Markdown
Member

Closes the gap in Intern #926: \Seen was readable everywhere (mail_search reports it, unseen_only filters on it) and writable nowhere, so an agent could find unread mail and never clear it -- which is exactly what cleaning an inbox down after a mass mailing needs.

What's new

mail_mark_read(uids, folder="INBOX", read=True, account=None). read=false puts messages back to unread. No confirm= gate, same as mail_flag: it alters no message and the same tool takes it straight back off.

Decisions worth naming

  • A sibling of flag(), not an argument to it. set_seen is its own protocol method because the star and the read state are independent markers -- one call would have to be told which one it was not changing.
  • The same raw UID STORE, now shared as _store(). imap-tools' mb.flag follows every STORE with an EXPUNGE, which permanently drops whatever another mail client left marked \Deleted. A \Seen sweep is precisely the operation that touches every message in a folder, so it gets the same treatment the flag already had.
  • Reading still does not mark. Every fetch keeps mark_seen=False. "What have I not looked at" stays the user's own answer rather than a side effect of an agent looking, so marking is always deliberate -- and the tool docstring and knowledge.py say so.
  • Refusing beats dropping. The tool reads the method as getattr(provider, "set_seen", None) and refuses clearly when absent, the same rule as outgoing attachments: a backend written before this would otherwise report a sweep it never made.

Tests

  • tests/test_imap_flag.py -- both STORE command shapes, the absent EXPUNGE, uid injection refused, empty list a no-op.
  • tests/test_tools.py -- annotations (write, non-destructive, idempotent, no confirm), a mark/unmark round trip visible through mail_search and unseen_only, and the refusal for a backend without the method.
  • tests/integration/test_greenmail_e2e.py -- round trip against a real IMAP server, including that unseen_only then agrees with what was written.

make lint and make test green (246 passed). The GreenMail job needs Docker, which this session has none of, so test_mark_read_and_unread_round_trip has not run locally -- CI covers it.

Companion PR in squirrel-mcp-admin implements set_seen on the Graph backend; it needs a SQUIRREL_MCP_REF bump to this commit once merged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gf8kB8Mrx9GaGrEQNnwQ4v


Generated by Claude Code

The \Seen marker was readable everywhere -- mail_search reports it,
unseen_only filters on it -- and writable nowhere, so an agent could
find the unread mail and never clear it. Cleaning an inbox down after a
mass mailing needs both halves.

mail_mark_read sets it, read=false takes it back off. Two decisions:

- A sibling of flag(), not an argument to it. The markers are
  independent states, and a single call would have to be told which one
  it was not changing.
- The same raw UID STORE, now shared as _store(). imap-tools' mb.flag
  follows every STORE with an EXPUNGE, which drops whatever another
  client left marked \Deleted -- and a \Seen sweep is precisely the
  operation that touches every message in the folder.

Reading still does not mark: every fetch keeps mark_seen=False, so
"what have I not looked at" stays the user's answer rather than a side
effect of an agent looking.

The tool reads the provider method through getattr and refuses clearly
when it is absent, the same rule as outgoing attachments: a backend
from before this would otherwise report a sweep it never made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gf8kB8Mrx9GaGrEQNnwQ4v
@rutgerhofste
rutgerhofste marked this pull request as ready for review September 16, 2026 07:52
@rutgerhofste
rutgerhofste merged commit ef48ad7 into main Sep 16, 2026
3 checks passed
@rutgerhofste
rutgerhofste deleted the claude/upbeat-cori-8cf9kh branch September 16, 2026 07:52
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