Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions scripts/mcp_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"mail_send",
"mail_move",
"mail_flag",
"mail_mark_read",
}


Expand Down
7 changes: 7 additions & 0 deletions squirrel_mcp/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions squirrel_mcp/providers/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 13 additions & 4 deletions squirrel_mcp/providers/soverin/imap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions squirrel_mcp/providers/soverin/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions squirrel_mcp/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) =====================


Expand Down
62 changes: 60 additions & 2 deletions squirrel_mcp/tools/mail/organize.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
4 changes: 3 additions & 1 deletion squirrel_mcp/tools/mail/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,14 +81,17 @@ 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,
subject="Hello",
from_addr="Anna <anna@example.com>",
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.",
Expand All @@ -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="<abc@example.com>",
body_text="Hello world. " * 100,
body_length=len("Hello world. " * 100),
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/integration/test_greenmail_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading