You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Mail tools can list accounts, mailboxes and messages, read a message, and send mail, but they can't organize messages. Add tools to move messages between mailboxes, set or clear flags (read/unread, starred, answered) and tag messages, so an inbox can be triaged through the MCP server.
Which Mail API to use
The Mail app's OCS API only covers reading and sending (checked against nextcloud/mailmain). Paths are relative to /ocs/v2.php/:
OCS endpoint
Used by
GET apps/mail/account/list
list_mail_accounts
GET apps/mail/ocs/mailboxes?accountId=
list_mailboxes
GET apps/mail/ocs/mailboxes/{mailboxId}/messages
list_mail_messages
GET apps/mail/message/{id} (also /raw, /attachment/{attachmentId})
get_mail_message
POST apps/mail/message/send
send_mail
There is no OCS endpoint for moving, flagging or tagging. These exist only as the Mail web UI's JSON routes (appinfo/routes.php, lib/Controller/MessagesController.php, lib/Controller/TagsController.php). Paths are relative to /index.php/apps/mail:
Action
Route
Request body
Move a message
POST /api/messages/{id}/move
{"destFolderId": <mailbox id>}
Set flags
PUT /api/messages/{id}/flags
{"flags": {"seen": true, "flagged": false}}
Tag a message
PUT /api/messages/{id}/tags/{imapLabel}
none
Untag a message
DELETE /api/messages/{id}/tags/{imapLabel}
none
Create a tag
POST /api/tags
{"displayName": "...", "color": "..."}
Update a tag
PUT /api/tags/{id}
{"displayName": "...", "color": "..."}
Delete a tag
DELETE /api/tags/{accountId}/delete/{id}
none
What to know about these routes:
CSRF (main risk): the controller methods aren't marked #[NoCSRFRequired]. Nextcloud skips the CSRF check for requests that send OCS-APIRequest: true, so Basic auth with an app password should work with that header. Confirm this with an integration test before building the rest.
Flags:setFlags loops over flag => bool and calls MailManager::flagMessage(). Supported names are in the map at the top of lib/Service/MailManager.php: seen, answered, flagged, deleted, draft, recent.
Moving returns nothing, and the message ID changes.move returns an empty response. IMAP UIDs are per mailbox, so the moved message gets a new UID, and a new database ID once the destination mailbox is synced. Callers must not reuse the old ID. MailManager::moveMessage() also supports moving to a mailbox in another account.
Tags belong to the user and are addressed by IMAP label.MailManager::createTag() derives the label from the display name and returns the existing tag if that label already exists, so POST /api/tags is create-or-get and returns the tag as JSON. No JSON route lists all tags (the web UI reads them from page initial state), but message JSON (Db\Message::jsonSerialize()) includes tags, keyed by IMAP label, next to flags. TagsController validates the display name and color.
Prior art: cbcoutinho/nextcloud-mcp-server calls these routes with OCS-APIRequest: true (see its docs/mail.md). It's AGPL-3.0 and this repo is MIT, so use it only to confirm behavior. Don't copy code from it.
Proposed changes
Client (src/nc_mcp_server/client.py)
Add a helper for non-OCS app routes, for example app_request_json(method, path, json_data=None, params=None). It should:
call /index.php/apps/{path} with the OCS-APIRequest: true header and an optional JSON body
parse plain JSON responses (there's no OCS envelope) and handle empty bodies
raise NextcloudError on non-2xx, like the existing helpers
go through _do_request, so retries and auth handling stay the same
move_mail_message: the docstring must say the old message ID stops being valid after the move.
set_mail_message_flags: send only the flags that were passed, and reject calls that pass none. Leave out deleted, draft and recent; if deleting is added later, it belongs behind destructive.
create_mail_tag: return id, display_name, imap_label and color.
Add tags (display name and IMAP label) to the output of list_mail_messages and get_mail_message, so callers can see current tags and look up labels.
Updating or deleting tags is optional. If added, deleting needs destructive.
Tests
Unit tests in tests/, following tests/test_client_errors.py: the new client helper (URL, OCS-APIRequest header, JSON body, empty responses, error mapping) and the tools' argument validation.
Integration tests in tests/integration/test_mail.py, reusing _send_test_email, _sync_mail_account, _get_account_id and _get_inbox_id:
move a freshly sent message to another mailbox and find it there after sync
set and clear seen and flagged, and check the result with get_mail_message or list_mail_messages
create the same tag twice and get the same label back; tag and untag a message and check tags in the output
extend TestMailPermissions: read-only blocks each new tool, write allows it
CI uses smtp4dev as the IMAP server (.github/workflows/tests-integration.yml). Check early that its IMAP server supports MOVE, flag changes and custom keywords (tags). If it doesn't, move the Mail tests to a fuller IMAP server instead of skipping them (cbcoutinho's project uses GreenMail).
Docs (README.md)
Add the new tools to the Mail tool table (around line 343).
Update the Mail row in the category table (line 57). It currently lists "accounts, mailboxes, messages, send" with protocol "OCS", but the new tools use the Mail app's JSON routes.
Fix the tool count: the heading says 140 tools, but the server already registers 156.
Acceptance criteria
The five tools are registered with the permission levels and annotations above
list_mail_messages and get_mail_message include tags
Unit and integration tests cover success, validation errors and permission checks
Mail integration tests pass in CI
README updated
Notes
Integration CI is currently red for unrelated reasons (see the runs on Pin mcp below 2.0 to fix fresh installs #58): admin endpoints return "Password confirmation is required", Circles calls get HTTP 429, and the NC 33 job can't create an app password during setup. None of those failures are in test_mail.py.
ocs/mailboxes/{mailboxId}/messages returns the threaded view unless view=singleton is passed, and list_mail_messages doesn't pass view. Check which one triage needs.
Out of scope, possible follow-ups: replying in a thread (send_mail has no In-Reply-To/References support), exposing the list endpoint's filter parameter for server-side search, deleting messages.
Summary
The Mail tools can list accounts, mailboxes and messages, read a message, and send mail, but they can't organize messages. Add tools to move messages between mailboxes, set or clear flags (read/unread, starred, answered) and tag messages, so an inbox can be triaged through the MCP server.
Which Mail API to use
The Mail app's OCS API only covers reading and sending (checked against
nextcloud/mailmain). Paths are relative to/ocs/v2.php/:GET apps/mail/account/listlist_mail_accountsGET apps/mail/ocs/mailboxes?accountId=list_mailboxesGET apps/mail/ocs/mailboxes/{mailboxId}/messageslist_mail_messagesGET apps/mail/message/{id}(also/raw,/attachment/{attachmentId})get_mail_messagePOST apps/mail/message/sendsend_mailThere is no OCS endpoint for moving, flagging or tagging. These exist only as the Mail web UI's JSON routes (
appinfo/routes.php,lib/Controller/MessagesController.php,lib/Controller/TagsController.php). Paths are relative to/index.php/apps/mail:POST /api/messages/{id}/move{"destFolderId": <mailbox id>}PUT /api/messages/{id}/flags{"flags": {"seen": true, "flagged": false}}PUT /api/messages/{id}/tags/{imapLabel}DELETE /api/messages/{id}/tags/{imapLabel}POST /api/tags{"displayName": "...", "color": "..."}PUT /api/tags/{id}{"displayName": "...", "color": "..."}DELETE /api/tags/{accountId}/delete/{id}What to know about these routes:
#[NoCSRFRequired]. Nextcloud skips the CSRF check for requests that sendOCS-APIRequest: true, so Basic auth with an app password should work with that header. Confirm this with an integration test before building the rest.setFlagsloops overflag => booland callsMailManager::flagMessage(). Supported names are in the map at the top oflib/Service/MailManager.php:seen,answered,flagged,deleted,draft,recent.movereturns an empty response. IMAP UIDs are per mailbox, so the moved message gets a new UID, and a new database ID once the destination mailbox is synced. Callers must not reuse the old ID.MailManager::moveMessage()also supports moving to a mailbox in another account.MailManager::createTag()derives the label from the display name and returns the existing tag if that label already exists, soPOST /api/tagsis create-or-get and returns the tag as JSON. No JSON route lists all tags (the web UI reads them from page initial state), but message JSON (Db\Message::jsonSerialize()) includestags, keyed by IMAP label, next toflags.TagsControllervalidates the display name and color.Prior art: cbcoutinho/nextcloud-mcp-server calls these routes with
OCS-APIRequest: true(see itsdocs/mail.md). It's AGPL-3.0 and this repo is MIT, so use it only to confirm behavior. Don't copy code from it.Proposed changes
Client (
src/nc_mcp_server/client.py)Add a helper for non-OCS app routes, for example
app_request_json(method, path, json_data=None, params=None). It should:/index.php/apps/{path}with theOCS-APIRequest: trueheader and an optional JSON bodyNextcloudErroron non-2xx, like the existing helpers_do_request, so retries and auth handling stay the sameTools (
src/nc_mcp_server/tools/mail.py)move_mail_message(message_id, destination_mailbox_id)ADDITIVEset_mail_message_flags(message_id, seen=None, flagged=None, answered=None)ADDITIVE_IDEMPOTENTcreate_mail_tag(display_name, color)ADDITIVE_IDEMPOTENTadd_mail_message_tag(message_id, imap_label)ADDITIVE_IDEMPOTENTremove_mail_message_tag(message_id, imap_label)ADDITIVE_IDEMPOTENTmove_mail_message: the docstring must say the old message ID stops being valid after the move.set_mail_message_flags: send only the flags that were passed, and reject calls that pass none. Leave outdeleted,draftandrecent; if deleting is added later, it belongs behinddestructive.create_mail_tag: returnid,display_name,imap_labelandcolor.tags(display name and IMAP label) to the output oflist_mail_messagesandget_mail_message, so callers can see current tags and look up labels.destructive.Tests
tests/, followingtests/test_client_errors.py: the new client helper (URL,OCS-APIRequestheader, JSON body, empty responses, error mapping) and the tools' argument validation.tests/integration/test_mail.py, reusing_send_test_email,_sync_mail_account,_get_account_idand_get_inbox_id:seenandflagged, and check the result withget_mail_messageorlist_mail_messagestagsin the outputTestMailPermissions: read-only blocks each new tool, write allows it.github/workflows/tests-integration.yml). Check early that its IMAP server supports MOVE, flag changes and custom keywords (tags). If it doesn't, move the Mail tests to a fuller IMAP server instead of skipping them (cbcoutinho's project uses GreenMail).Docs (
README.md)Acceptance criteria
list_mail_messagesandget_mail_messageinclude tagsNotes
test_mail.py.ocs/mailboxes/{mailboxId}/messagesreturns the threaded view unlessview=singletonis passed, andlist_mail_messagesdoesn't passview. Check which one triage needs.send_mailhas noIn-Reply-To/Referencessupport), exposing the list endpoint'sfilterparameter for server-side search, deleting messages.