HTTP API

Every endpoint ecr-server exposes, with its shapes and status codes.

Base path /api/v1. Every route except /health requires Authorization: Bearer <token>. If no device tokens exist the server runs unauthenticated and logs a warning.

🔗Routes

MethodPathNotes
GET/healthFull doctor report. Unauthenticated.
GET/revision{ uuid, lastmod }
GET/accountsAccounts with their real folder trees
GET/addressesRanked addresses for the composer's completion
GET/tagsEvery tag in the database, for query completion
POST/counts{ queries: [] } → { counts: [] }, positional, via one notmuch count --batch. At most 200 queries
GET/lists{ lists, searchable }. searchable is false when index.header.List is unset
GET/threads?q=&limit=&offset=ETag; honours If-None-Match with 304. limit clamps to 1..500
GET/threads/{id}The messages in the thread. 404 if unknown
GET/messages/{id}One message with part metadata
GET/messages/{id}/body?html=&remote=Sanitized body. html defaults true, remote false. html=false is the markup read as Markdown, falling back to the text/plain part only when there is no markup. Carries signature and encrypted when the message is OpenPGP
GET/messages/{id}/parts/{n}Raw part bytes with content-type and disposition
POST/tags{ ops: [{ target, add, remove }] } → new revision. target is { "message": id } or { "thread": id }; a list action names the thread, so that every message in the conversation is written, and reading names the message
GET/foldersEvery maildir folder, as move destinations
POST/messages/{id}/move{ folder }. A rename into that folder's cur/. 400 for a folder that does not exist — ecr never creates one
POST/sync{ accounts: [] } → SyncReport. Empty means all
POST/send{ account, to, cc, bcc, subject, body, in_reply_to, references, attachments }, plus hold (seconds) or at (unix seconds). The draft is flattened into the request, not nested
GET/outboxWhat is queued: { id, account, due, subject, to, attempts, last_error }
DELETE/outbox/{id}Takes a message back. 400 once it has been claimed for sending — it may already be delivered
POST/outbox/{id}/retryMakes it due now and resets the attempt count, so the backoff starts over
GET/config{ path, raw }. An absent settings file is raw: "", not a 404
PUT/config{ raw }. Written only if it parses; 422 invalid_toml carries line and column
GET/themes{ dir, presets: [{ path, name, builtin }] }. Seeds the shipped presets
GET/theme?path={ path, raw }. path is relative to the config dir; a missing file is a 404. Seeds too
PUT/theme{ path, raw }. Same 422 invalid_toml as /config
GET/eventsSSE. Accepts ?access_token= because EventSource cannot set headers

path on the theme routes is user input from settings.toml, so it is resolved through MailPaths::resolve_relative: absolute paths, any .. component and anything that is not a .toml file are 400, never clamped. A theme therefore cannot name a file outside ~/.config/ecr/.

Both theme routes seed the shipped presets into ~/.config/ecr/themes/, and neither overwrites a file that is already there. The read seeds as well as the listing because a client asks for the theme its default setting names — themes/ecr-dark.toml — long before anything asks for the listing: seeding on the listing alone left a fresh install answering 404 for the palette ecr ships with, until the settings page had been opened once.

🔗Server-sent events

Event names and payloads:

mail:changed    { revision }
tags:changed    { revision, ids }
sync:started    { accounts }
sync:progress   { line }
sync:finished   { new_messages, revision }
outbox:changed  {}
error           { detail }

outbox:changed carries nothing on purpose — a client asks /outbox for the state rather than being handed a copy that may already be out of date by the time it is read. A client that does not subscribe to it shows a queue that never changes, which is how a failed send became invisible.

🔗Errors

{ "error": "not_found", "detail": "no message with id x@y.z" }
StatusWhen
400Invalid tag, unsendable draft, unknown send account, write attempted in --read-only
401Missing or wrong bearer token
404No such message, thread or part
503A required binary is missing or the mail config cannot be resolved — an environment problem, not a bug
500Anything else

🔗Examples

TOKEN=$(cargo run -q -p ecr-cli -- token new laptop)

curl -s localhost:8383/api/v1/health | jq '.checks[] | select(.status != "ok")'

curl -s -H "Authorization: Bearer $TOKEN" \
  'localhost:8383/api/v1/threads?q=tag:inbox&limit=20' | jq '.total'

curl -s -H "Authorization: Bearer $TOKEN" -X POST \
  -H 'content-type: application/json' \
  -d '{"ops":[{"target":{"thread":"0000000000000abc"},"add":["flagged"],"remove":["unread"]}]}' \
  localhost:8383/api/v1/tags

curl -N -H "Authorization: Bearer $TOKEN" localhost:8383/api/v1/events