From 43c0e385e8d113fc58cfb6cc8f5ce65b6cd64492 Mon Sep 17 00:00:00 2001 From: Rutger Date: Wed, 16 Sep 2026 07:42:25 +0000 Subject: [PATCH] Mark mail read or unread, the sibling of the flag 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 Claude-Session: https://claude.ai/code/session_01Gf8kB8Mrx9GaGrEQNnwQ4v --- CLAUDE.md | 36 +++++++++---- README.md | 12 +++-- scripts/mcp_smoke.py | 1 + squirrel_mcp/knowledge.py | 7 +++ squirrel_mcp/providers/protocol.py | 14 +++++ squirrel_mcp/providers/soverin/imap.py | 17 ++++-- squirrel_mcp/providers/soverin/provider.py | 3 ++ squirrel_mcp/schemas.py | 10 ++++ squirrel_mcp/tools/mail/organize.py | 62 +++++++++++++++++++++- squirrel_mcp/tools/mail/query.py | 4 +- tests/conftest.py | 20 ++++++- tests/integration/test_greenmail_e2e.py | 32 +++++++++++ tests/test_imap_flag.py | 48 ++++++++++++++--- tests/test_tools.py | 48 +++++++++++++++++ 14 files changed, 283 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcbd81d..aa74249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,10 @@ namespaces are reserved so they plug in later as sibling providers + tool mixins IMAP, stdlib `smtplib`/`email` for SMTP) instead of hand-rolling protocol code. 4. **Confirm before it leaves or changes** -- sending and moving/deleting require an explicit `confirm=true` and are flagged `destructiveHint`. The line is - "could the user not get this back", not "is this a write": `mail_flag` sets - and clears the same marker with the same tool and alters no message, so - gating it would only teach clients that the confirm prompt is noise. + "could the user not get this back", not "is this a write": `mail_flag` and + `mail_mark_read` set and clear the same marker with the same tool and alter + no message, so gating them would only teach clients that the confirm prompt + is noise. 5. **No fallbacks** -- explicit config or a clear error, never guess credentials. 6. **Open core** -- public package works standalone (stdio, one mailbox from env); the private package adds SaaS features via well-defined seams. @@ -57,13 +58,28 @@ namespaces are reserved so they plug in later as sibling providers + tool mixins fails at first call, not at import -- the test moves that to `make test`), and the tool layer contains no reference to a concrete client, which used to be only a convention below. -- `SoverinImapClient.flag` is the one deliberate exception to principle 3: it - issues its own `UID STORE` instead of calling `imap-tools`' `mb.flag`, because - that helper follows every STORE with an `EXPUNGE` -- which would permanently - drop whatever another mail client left marked `\Deleted` in the folder. - Flagging is advertised as non-destructive, so it does not get to delete - anything. `tests/test_imap_flag.py` pins the command shape and the absent - expunge; the GreenMail e2e proves both against a real server. +- **The two markers are siblings, and both write their own STORE.** + `flag` (`\Flagged`, the star) and `set_seen` (`\Seen`, read/unread) are the + one deliberate exception to principle 3: `SoverinImapClient._store` issues + its own `UID STORE` instead of calling `imap-tools`' `mb.flag`, because that + helper follows every STORE with an `EXPUNGE` -- which would permanently drop + whatever another mail client left marked `\Deleted` in the folder. Marking is + advertised as non-destructive, so it does not get to delete anything, and the + `\Seen` sweep is where that bites hardest: clearing an inbox after a mass + mailing touches every message in it. They are **two methods, not one method + with a marker argument**, because they are independent states and a single + call would have to be told which one it was *not* changing. + `mail_mark_read` is the tool, `read=false` puts a message back to unread, and + it needs no `confirm=` for the same reason `mail_flag` does not. Deliberately + **reading does not mark**: every `fetch` passes `mark_seen=False`, so "what + have I not looked at" stays the user's answer rather than a side effect of an + agent looking, and marking is always something the user asked for. The tool + reads the provider method as `getattr(provider, "set_seen", None)` and refuses + clearly when it is absent -- a backend from before this existed would + otherwise report a sweep it never made (the attachments rule again). + `tests/test_imap_flag.py` pins both command shapes and the absent expunge; + the GreenMail e2e proves both against a real server, including that + `unseen_only` then agrees with what was written. - **A search query is parsed once, here, not interpreted by each backend.** `search_query.py` owns the grammar (`from:`/`to:`/`cc:`/`subject:`/`body:`, `"phrases"`, `-exclusions`, `OR`, `has:attachment`) and both backends compile diff --git a/README.md b/README.md index e6916c1..c1fd6f1 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,14 @@ is configured. | `mail_send` | Send a message, attachments included — **requires `confirm=true`** | ⚠️ outgoing | | `mail_move` | Move messages between folders — **requires `confirm=true`** | ⚠️ mutating | | `mail_flag` | Flag / unflag messages — the star, `flagged=false` clears it | reversible | - -Flagging and finding are two halves of one thing: `mail_flag` sets the marker, -`mail_search(flagged_only=true)` gets those messages back — filtered by the -server, not by paging a folder. +| `mail_mark_read` | Mark messages read / unread, `read=false` clears it | reversible | + +Marking and finding are two halves of one thing: `mail_flag` sets the star and +`mail_search(flagged_only=true)` gets those messages back; `mail_mark_read` +sets the read state and `mail_search(unseen_only=true)` gets back what is left +— filtered by the server, not by paging a folder. Reading a message with +`mail_read` does *not* mark it read, so "what I have not looked at yet" stays +yours to answer. **Attachments** go both ways. `mail_read` lists them and `mail_get_attachment` downloads one; sending takes an `attachments` list on `mail_send`, `mail_draft` diff --git a/scripts/mcp_smoke.py b/scripts/mcp_smoke.py index d5f5e12..f090af8 100644 --- a/scripts/mcp_smoke.py +++ b/scripts/mcp_smoke.py @@ -25,6 +25,7 @@ "mail_send", "mail_move", "mail_flag", + "mail_mark_read", } diff --git a/squirrel_mcp/knowledge.py b/squirrel_mcp/knowledge.py index fd85e39..f6bb3d2 100644 --- a/squirrel_mcp/knowledge.py +++ b/squirrel_mcp/knowledge.py @@ -79,6 +79,13 @@ confirmation needed, because it changes no message and is reversible by the same tool, so it is the natural way to mark things for the user to follow up on. Flagged messages come back from mail_search with "\\Flagged" in `flags`. +- mail_mark_read: mark messages read, or `read=false` to put them back to + unread -- the bold-or-not state, and how an inbox is cleared down after a + mass mailing. No confirmation needed, for the same reason as mail_flag. + Note that mail_read does NOT mark anything read: what is unread stays the + user's own answer to "what have I not looked at", so say what you are about + to sweep before sweeping it. `unseen_only=true` on mail_search finds what is + left. Contacts tools (`contacts_*`, CardDAV): contacts_list_addressbooks · contacts_search (paginated) · contacts_read · contacts_create / contacts_update / contacts_delete diff --git a/squirrel_mcp/providers/protocol.py b/squirrel_mcp/providers/protocol.py index 3e35d5d..4aedeca 100644 --- a/squirrel_mcp/providers/protocol.py +++ b/squirrel_mcp/providers/protocol.py @@ -346,6 +346,20 @@ def flag(self, folder: str, uids: List[str], flagged: bool = True) -> int: """ ... + def set_seen(self, folder: str, uids: List[str], seen: bool = True) -> int: + """Set or clear the ``\\Seen`` marker on messages. Returns count changed. + + The read/unread state every mail client draws as bold-or-not. A + sibling of ``flag`` rather than an argument to it: they are two + independent markers, and a tool that took both would have to be told + which one it was not changing. + + Reading a message does *not* set it -- ``read`` fetches without + marking, so what is unread stays the user's answer to "what have I not + looked at", not a side effect of an agent looking. + """ + ... + # --------------------------------------------------------------------------- # # Calendar pillar (CalDAV) -- value objects + protocol. diff --git a/squirrel_mcp/providers/soverin/imap.py b/squirrel_mcp/providers/soverin/imap.py index 56104ad..f19ac4d 100644 --- a/squirrel_mcp/providers/soverin/imap.py +++ b/squirrel_mcp/providers/soverin/imap.py @@ -447,11 +447,20 @@ def op(mb: BaseMailBox) -> int: return self._run(op) def flag(self, folder: str, uids: List[str], flagged: bool = True) -> int: + return self._store(folder, uids, MailMessageFlags.FLAGGED, flagged) + + def set_seen(self, folder: str, uids: List[str], seen: bool = True) -> int: + return self._store(folder, uids, MailMessageFlags.SEEN, seen) + + def _store(self, folder: str, uids: List[str], marker: str, on: bool) -> int: # Deliberately a raw UID STORE rather than imap_tools' ``mb.flag``: # that helper follows every STORE with an EXPUNGE, which permanently # removes anything another client left marked \Deleted in this folder. - # Flagging is meant to be the one mail write you can undo, so it does - # not get to delete messages as a side effect. + # Marking is meant to be the one mail write you can undo, so it does + # not get to delete messages as a side effect. \Seen rides the same + # path for the same reason -- and because a \Deleted message another + # client is holding is exactly the kind of thing an inbox clean-up + # sweeps \Seen across. try: # Rejects anything that is not a bare uid, so a crafted argument # cannot smuggle extra IMAP into the STORE command below. @@ -466,8 +475,8 @@ def op(mb: BaseMailBox) -> int: result = mb.client.uid( "STORE", ",".join(cleaned), - ("+" if flagged else "-") + "FLAGS", - f"({MailMessageFlags.FLAGGED})", + ("+" if on else "-") + "FLAGS", + f"({marker})", ) try: check_command_status(result, MailboxFlagError) diff --git a/squirrel_mcp/providers/soverin/provider.py b/squirrel_mcp/providers/soverin/provider.py index 9fa6ee3..d2c0633 100644 --- a/squirrel_mcp/providers/soverin/provider.py +++ b/squirrel_mcp/providers/soverin/provider.py @@ -176,6 +176,9 @@ def move(self, folder: str, uids: List[str], destination: str) -> int: def flag(self, folder: str, uids: List[str], flagged: bool = True) -> int: return self._imap.flag(folder, uids, flagged) + def set_seen(self, folder: str, uids: List[str], seen: bool = True) -> int: + return self._imap.set_seen(folder, uids, seen) + # ---- send (SMTP) ----------------------------------------------------- # def send( self, diff --git a/squirrel_mcp/schemas.py b/squirrel_mcp/schemas.py index d68d326..5993a48 100644 --- a/squirrel_mcp/schemas.py +++ b/squirrel_mcp/schemas.py @@ -195,6 +195,16 @@ class FlagResult(BaseModel): uids: List[str] = Field(default_factory=list) +class SeenResult(BaseModel): + changed: int = Field( + description="Number of messages the read/unread change was applied to " + "(a uid no longer in the folder is skipped)" + ) + read: bool = Field(description="True if the messages were marked read, false if unread") + folder: str + uids: List[str] = Field(default_factory=list) + + # ===================== Calendar (CalDAV) ===================== diff --git a/squirrel_mcp/tools/mail/organize.py b/squirrel_mcp/tools/mail/organize.py index 3fd42c6..fe0f6b3 100644 --- a/squirrel_mcp/tools/mail/organize.py +++ b/squirrel_mcp/tools/mail/organize.py @@ -1,5 +1,6 @@ """mail_move -- move messages between folders (requires confirmation). mail_flag -- set or clear the \\Flagged marker (no confirmation needed). +mail_mark_read -- set or clear the \\Seen marker (no confirmation needed). """ from __future__ import annotations @@ -9,12 +10,12 @@ from mcp.types import ToolAnnotations from ...error_handling import ValidationError -from ...schemas import FlagResult, MoveResult +from ...schemas import FlagResult, MoveResult, SeenResult from .._common import as_str_list, require_confirm, run_blocking class OrganizeToolsMixin: - """Move messages between folders, and flag/unflag them.""" + """Move messages between folders, flag them, and mark them read or unread.""" def _register_organize_tools(self): @self.app.tool( @@ -103,3 +104,60 @@ async def mail_flag( folder=folder, uids=uid_list, ) + + @self.app.tool( + title="Mark Mail Read", + annotations=ToolAnnotations( + # Same shape as mail_flag, and for the same reason: \Seen is a + # marker, not the message, and this tool takes it straight back + # off again. Nothing to gate. + readOnlyHint=False, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, + ), + ) + async def mail_mark_read( + uids: Any, + folder: str = "INBOX", + read: bool = True, + account: Optional[str] = None, + ) -> SeenResult: + """Mark one or more messages read, or put them back to unread. + + Args: + uids: Message uids from mail_search in ``folder``. Accepts a list + or a comma-separated string. + folder: Folder the messages live in (default "INBOX"). Must be the + folder the uids came from -- a uid is only meaningful there. + read: True to mark them read, false to mark them unread again. + account: Which email account (id or address from + mail_list_accounts). Omit when only one is configured. + + This is the bold-or-not state every mail client shows, and it is how + an inbox is cleared down after a mass mailing. Reading a message with + mail_read does *not* set it -- what is unread stays the user's own + answer to "what have I not looked at yet" -- so marking is always + deliberate. Messages marked read come back from mail_search with + "\\Seen" in their ``flags``, and ``unseen_only=true`` finds the rest. + """ + provider, sub = await self._get_provider(account, writes=True) + uid_list = as_str_list(uids) + if not uid_list: + raise ValidationError("'uids' is required (at least one message uid)") + # Refusing beats dropping: a backend from before this existed would + # otherwise report a clean sweep it never made. + set_seen = getattr(provider, "set_seen", None) + if set_seen is None: + raise ValidationError( + "This mail account cannot change read/unread state. " + "mail_flag works on it, and marks messages just as visibly." + ) + changed = await run_blocking(provider, set_seen, folder, uid_list, read) + self._track_usage(sub, "mail_mark_read") + return SeenResult( + changed=changed, + read=read, + folder=folder, + uids=uid_list, + ) diff --git a/squirrel_mcp/tools/mail/query.py b/squirrel_mcp/tools/mail/query.py index 3e21c51..e9cc187 100644 --- a/squirrel_mcp/tools/mail/query.py +++ b/squirrel_mcp/tools/mail/query.py @@ -130,7 +130,9 @@ async def mail_search( mail_list_folders. limit: Page size. Defaults to the server default, capped at the max. offset: Number of messages to skip (for paging). - unseen_only: If true, only unread messages. + unseen_only: If true, only unread messages -- what is left + after a mail_mark_read sweep, and what "clean up my inbox" + works through. flagged_only: If true, only messages carrying the \\Flagged marker (set with mail_flag). This is the way to answer "what have I flagged" -- the server does the filtering, so diff --git a/tests/conftest.py b/tests/conftest.py index 8eed7f2..7205c8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,10 @@ def __init__(self): # Uids currently carrying \Flagged, so search/fetch report what flag() # did and a set-then-clear round trip is actually observable. self.flagged: set = set() + # Same for \Seen, so mark-read/unread is observable through search and + # fetch rather than asserted on the call alone. Starts read, which is + # what this fake always reported before the marker was writable. + self.seen: set = {"101"} # Flipped off by a test to stand in for a backend that cannot send # files, which is what every backend answers by default. self.attachments_supported = True @@ -77,6 +81,8 @@ def search( ) -> Tuple[List[MessageSummary], int]: if flagged_only and "101" not in self.flagged: return [], 0 + if unseen_only and "101" in self.seen: + return [], 0 msg = MessageSummary( uid="101", folder=folder, @@ -84,7 +90,8 @@ def search( from_addr="Anna ", to_addrs=["me@example.com"], date="Mon, 01 Jan 2026 10:00:00 +0000", - flags=["\\Seen"] + (["\\Flagged"] if "101" in self.flagged else []), + flags=(["\\Seen"] if "101" in self.seen else []) + + (["\\Flagged"] if "101" in self.flagged else []), size=1234, has_attachments=True, preview="Hi there, this is a preview.", @@ -101,7 +108,8 @@ def fetch_message(self, folder: str, uid: str) -> MessageDetail: cc_addrs=["carol@example.com"], reply_to_addrs=list(self.reply_to_addrs), date="Mon, 01 Jan 2026 10:00:00 +0000", - flags=["\\Seen"] + (["\\Flagged"] if uid in self.flagged else []), + flags=(["\\Seen"] if uid in self.seen else []) + + (["\\Flagged"] if uid in self.flagged else []), message_id="", body_text="Hello world. " * 100, body_length=len("Hello world. " * 100), @@ -185,6 +193,14 @@ def flag(self, folder, uids, flagged: bool = True) -> int: self.flagged.discard(uid) return len(uids) + def set_seen(self, folder, uids, seen: bool = True) -> int: + for uid in uids: + if seen: + self.seen.add(uid) + else: + self.seen.discard(uid) + return len(uids) + @pytest.fixture def fake_provider() -> FakeMailProvider: diff --git a/tests/integration/test_greenmail_e2e.py b/tests/integration/test_greenmail_e2e.py index 5ffbb1f..b78e206 100644 --- a/tests/integration/test_greenmail_e2e.py +++ b/tests/integration/test_greenmail_e2e.py @@ -170,6 +170,38 @@ def test_flag_and_unflag_round_trip(provider): assert _find_by_subject(only, subject) is None +def test_mark_read_and_unread_round_trip(provider): + """\\Seen against a real server, and against the unread filter. + + Same reason as the flag round trip: ``set_seen`` writes its own ``UID + STORE``. The extra thing proved here is that the server then agrees -- + ``unseen_only`` is answered by IMAP's own ``UNSEEN`` key, so a marker the + tool set has to be the one the search reads. + """ + p, cfg = provider + subject = f"E2E seen {uuid.uuid4().hex[:8]}" + _seed_message(cfg.smtp_host, cfg.smtp_port, subject, "Mark me.") + + inbox, _ = p.search("INBOX", limit=50) + target = _find_by_subject(inbox, subject) + assert target is not None + assert "\\Seen" not in target.flags + unread, _ = p.search("INBOX", unseen_only=True, limit=50) + assert _find_by_subject(unread, subject) is not None + + assert p.set_seen("INBOX", [target.uid]) == 1 + inbox, _ = p.search("INBOX", limit=50) + assert "\\Seen" in _find_by_subject(inbox, subject).flags + unread, _ = p.search("INBOX", unseen_only=True, limit=50) + assert _find_by_subject(unread, subject) is None + + assert p.set_seen("INBOX", [target.uid], seen=False) == 1 + inbox, _ = p.search("INBOX", limit=50) + assert "\\Seen" not in _find_by_subject(inbox, subject).flags + unread, _ = p.search("INBOX", unseen_only=True, limit=50) + assert _find_by_subject(unread, subject) is not None + + def test_flag_does_not_expunge_deleted_messages(provider): """Flagging must not take other messages with it. diff --git a/tests/test_imap_flag.py b/tests/test_imap_flag.py index cb2ce9b..853d6a7 100644 --- a/tests/test_imap_flag.py +++ b/tests/test_imap_flag.py @@ -1,11 +1,12 @@ -"""The IMAP flag path's command shape, without a server. - -``flag`` is the one provider method that builds its own IMAP command instead of -delegating to imap-tools, so the shape of that command is worth pinning on -every branch push -- the GreenMail e2e proves it works, but only in the -integration job. The other half pinned here is what the method deliberately -does *not* do: imap-tools' own ``mb.flag`` follows each STORE with an EXPUNGE, -which would permanently drop anything another client left marked \\Deleted. +"""The IMAP marker path's command shape, without a server. + +``flag`` and ``set_seen`` are the provider methods that build their own IMAP +command instead of delegating to imap-tools, so the shape of that command is +worth pinning on every branch push -- the GreenMail e2e proves it works, but +only in the integration job. The other half pinned here is what they +deliberately do *not* do: imap-tools' own ``mb.flag`` follows each STORE with +an EXPUNGE, which would permanently drop anything another client left marked +\\Deleted. """ import pytest @@ -91,6 +92,37 @@ def test_a_uid_that_could_inject_imap_is_refused(client): assert client._mailbox.client.uid_calls == [] +def test_mark_read_stores_the_seen_marker(client): + assert client.set_seen("INBOX", ["101", "102"]) == 2 + mb = client._mailbox + assert mb.selected == "INBOX" + assert mb.client.uid_calls == [("STORE", "101,102", "+FLAGS", "(\\Seen)")] + + +def test_mark_unread_removes_it(client): + assert client.set_seen("INBOX", ["101"], seen=False) == 1 + assert client._mailbox.client.uid_calls == [("STORE", "101", "-FLAGS", "(\\Seen)")] + + +def test_mark_read_never_expunges(client): + """The \\Seen sweep is exactly where a stray EXPUNGE would bite: an inbox + clean-up touches every message, including whatever another client left + marked \\Deleted.""" + client.set_seen("INBOX", ["101"]) + assert client._mailbox.client.expunged == 0 + + +def test_mark_read_refuses_a_uid_that_could_inject_imap(client): + with pytest.raises(MailProviderError): + client.set_seen("INBOX", ["101 (\\Deleted)"]) + assert client._mailbox.client.uid_calls == [] + + +def test_mark_read_on_no_uids_touches_nothing(client): + assert client.set_seen("INBOX", []) == 0 + assert client._mailbox.client.uid_calls == [] + + def test_flagged_only_narrows_the_search_criteria(client): """The footgun this guards: imap-tools maps ``flagged=False`` to UNFLAGGED, which is the opposite of "no filter" -- so the key must be absent, not diff --git a/tests/test_tools.py b/tests/test_tools.py index 4f05dff..18f8120 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -21,6 +21,7 @@ "mail_send", "mail_move", "mail_flag", + "mail_mark_read", } @@ -161,6 +162,53 @@ async def test_search_can_narrow_to_flagged_messages(app_with_tools): assert "\\\\Flagged" in str(found) +async def test_mark_read_is_a_write_but_not_destructive(app_with_tools): + """\\Seen is the same kind of marker as \\Flagged: reversible by the same + tool, so it announces itself the same way and needs no confirm= either.""" + tools = {t.name: t for t in await app_with_tools.list_tools()} + mark = tools["mail_mark_read"] + assert mark.annotations.readOnlyHint is False + assert mark.annotations.destructiveHint is False + assert mark.annotations.idempotentHint is True + assert "confirm" not in mark.inputSchema.get("properties", {}) + + +async def test_mark_unread_round_trip_shows_up_in_search(app_with_tools, fake_provider): + """Both directions, because clearing \\Seen is the half a mail client + gives you and the inbox clean-up needs to be able to take back.""" + await app_with_tools.call_tool( + "mail_mark_read", {"uids": "101", "folder": "INBOX", "read": False} + ) + assert fake_provider.seen == set() + assert "\\\\Seen" not in str(await app_with_tools.call_tool("mail_search", {})) + # And the unread filter now finds it, which is what "clear the inbox" means. + assert "'total': 1" in str( + await app_with_tools.call_tool("mail_search", {"unseen_only": True}) + ) + + await app_with_tools.call_tool("mail_mark_read", {"uids": ["101"], "folder": "INBOX"}) + assert fake_provider.seen == {"101"} + assert "\\\\Seen" in str(await app_with_tools.call_tool("mail_search", {})) + + +async def test_mark_read_requires_at_least_one_uid(app_with_tools, fake_provider): + with pytest.raises(Exception) as exc: + await app_with_tools.call_tool("mail_mark_read", {"uids": "", "folder": "INBOX"}) + assert "uids" in str(exc.value) + assert fake_provider.seen == {"101"} + + +async def test_a_backend_without_set_seen_is_refused_not_ignored( + app_with_tools, fake_provider, monkeypatch +): + """Refusing beats dropping: a backend predating the marker would otherwise + report a clean sweep it never made.""" + monkeypatch.delattr(type(fake_provider), "set_seen") + with pytest.raises(Exception) as exc: + await app_with_tools.call_tool("mail_mark_read", {"uids": "101"}) + assert "read/unread" in str(exc.value) + + async def test_flag_requires_at_least_one_uid(app_with_tools, fake_provider): with pytest.raises(Exception) as exc: await app_with_tools.call_tool("mail_flag", {"uids": "", "folder": "INBOX"})