Threads
Inbox, archive, spam, and trash.
Archive finished work, hide spam, and undo a delete for 30 days. Markdown: /docs/folders.md · endpoint reference.
Archive a thread after it is handled
A thread with no folder row is in inbox. PATCH /api/v1/inboxes/{id}/threads/{threadId} accepts folder archive, spam, trash, or inbox, with or without add_labels and remove_labels. Moving to inbox deletes the row. The same folder twice does not change moved_at and does not emit thread.folder_updated.
Read uses the same permission as GET thread (message_read). Write uses the same permission as PATCH thread labels (message_update). An inbox-scoped key only sees its own inbox. Another account's thread is HTTP 404 thread_not_found. Another account's inbox is HTTP 404 inbox_not_found. A bad folder is HTTP 400 invalid_folder.
curl -s -X PATCH "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"folder":"archive","remove_labels":["unread"]}'List only open work
GET /api/v1/inboxes/{id}/threads, GET /api/v1/threads, thread search, GET /api/v1/inboxes/{id}/messages, per-inbox message search, and GET /api/v1/search accept folder. The values are inbox, archive, spam, trash, or all. When the parameter is absent, the list is inbox and archive. Spam and trash stay out. folder=all returns every folder. Existing filters still combine: unread, labels, assignee, unassigned, claimable, paging, and ordering.
Thread objects include folder and purge_after. purge_after is null unless the thread is in trash. Message objects include folder from their thread. Sent mail stays the direction=outbound filter. Sent is not a folder.
curl -s "https://cooperemail.com/api/v1/inboxes/$INBOX/threads?folder=inbox&unread=true" \ -H "Authorization: Bearer $COOPER_API_KEY"
A reply brings an archived thread back
A new inbound message on an archived thread deletes the folder row and returns the thread to inbox. The event is thread.folder_updated with reason new_message. A new inbound message on a spam or trash thread leaves the folder where it is. An outbound send or reply does not change the folder.
message.received, message.held, the event log, SSE, durable event consumers, MCP Events, tasks, and webhooks still fire for mail that lands on any thread, including archive, spam, and trash. Folder filters do not apply to those streams. GET /api/v1/inboxes/{id}/wait and GET /api/v1/inboxes/{id}/wait-for-code also keep their previous behavior and can return mail from any folder. The owner thread, owner digests, and the chat thread do not use the folder filter.
curl -s "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD" \ -H "Authorization: Bearer $COOPER_API_KEY"
Trash a thread and restore it within 30 days
folder trash sets purge_after to moved_at plus 30 days. Moving the thread to inbox, archive, or spam clears purge_after. DELETE /api/v1/inboxes/{id}/threads/{threadId} is still an immediate permanent delete of the messages, attachments, assignment, and folder row. Use folder trash when the delete should be undoable.
The daily cron that flushes expired inboxes, old events, and webhook deliveries also hard-deletes trash threads whose purge_after is in the past. It uses the same delete path as DELETE thread and removes at most 100 threads per run. Deleting a thread, an inbox, or a workspace removes its folder rows and does not emit thread.folder_updated.
curl -s -X PATCH "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"folder":"trash"}'
curl -s -X PATCH "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"folder":"inbox"}'Mark spam and block the sender
folder spam keeps later inbound mail in spam. block_sender true is accepted only together with folder spam. It adds the thread's latest inbound From address to that inbox's receive block list. The call is idempotent. The response includes blocked_sender, or null when the thread has no inbound From. block_sender without folder spam is HTTP 400 invalid_block_sender.
curl -s -X PATCH "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"folder":"spam","block_sender":true}'Move many threads in one call
POST /api/v1/inboxes/{id}/threads/move accepts {thread_ids, folder}. thread_ids is 1 to 100 unique ids. The response is {moved, not_found}. Ids stay in request order. An id that is not in this inbox is not_found and is not an error. A thread already in that folder is still listed in moved.
curl -s -X POST "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/move" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"thread_ids":["'"$THREAD"'"],"folder":"archive"}'Subscribe to thread.folder_updated
thread.folder_updated is stored in the event log and delivered through the same webhook path as other account events. The data is {inbox_id, thread_id, folder, previous_folder, reason, purge_after}. reason is api or new_message. The payload has no message body. A webhook receives it only when its events list names thread.folder_updated. A hook created without an events list still receives only message.received.
Omitting types on GET /api/v1/events, GET /api/v1/events/stream, and a durable event consumer returns every stored type, including thread.folder_updated once it exists. MCP Events stays message.received only.
MCP: cooper_update_thread accepts folder and block_sender. cooper_move_threads moves a batch. cooper_list_threads, cooper_search_threads, cooper_list_messages, cooper_search_messages, and cooper_search accept folder. Guide: https://cooperemail.com/docs/folders.
curl -s -X POST https://cooperemail.com/api/v1/webhooks \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"url":"https://example.com/hooks/cooper","events":["thread.folder_updated"],"inbox_id":"'"$INBOX"'"}'