Complete reference for every route under app/api/*. For a one-page quick index, see the REST API section of the README — this document goes deeper: query params, request/response bodies, side effects, and error cases for every endpoint, including the session-only routes not listed there.
https://<your-instance>/api
Nearly every route returns { data: T } on success or { error: string } on failure (per CLAUDE.md conventions). A handful of older routes predate that convention and return bare fields instead — each one is flagged ⚠ non-standard envelope below. Always check the route's documented shape rather than assuming { data }.
Two ways in, both handled transparently by route handlers that call authenticate() (lib/apiAuth.ts):
-
Session cookie — the normal browser login (Auth.js v5). Used by the app itself and by every route not marked Bearer below.
-
API key (Bearer token) — for scripts, agents, and integrations. Create one in Settings → Clés API (
POST /api/api-keys); the raw key is shown exactly once, at creation, and looks likesyn_<48 hex chars>. Send it as:curl -H "Authorization: Bearer syn_..." https://your-instance/api/accounts
Only routes explicitly marked 🔑 Bearer accept an API key — everything else requires the session cookie (some additionally require the admin role, marked 👑 Admin). middleware.ts runs at the Edge and only checks that some credential (cookie or Authorization header) is present; the actual key lookup and hashing happens server-side in each route via authenticate(). A key stops working immediately on revoke (DELETE /api/api-keys/[id], soft — sets revoked_at). Keys have no per-scope restriction beyond the fixed Bearer-eligible route list below — a key grants full read/write on every 🔑 route for that user's data.
Bearer-eligible routes (the complete list — nothing else accepts a key):
GET /api/accounts, GET /api/folders, GET /api/messages, GET /api/messages/[id], PATCH /api/messages/[id], DELETE /api/messages/[id], PATCH /api/messages/bulk, DELETE /api/messages/bulk, GET /api/messages/search, GET /api/messages/thread, POST /api/messages/send, GET /api/contacts.
Every other route — account/rule/template/signature/PGP/settings CRUD, admin, AI, OAuth, SSE, tracking, unsubscribe, and the account-mutation routes (POST/PATCH/DELETE /api/accounts...) — is session-only, even where the underlying resource is otherwise Bearer-eligible for reads.
401 Unauthorized— no valid session and no valid/unrevoked Bearer key.403 Forbidden— authenticated but missing the required role (admin routes) or a disabled feature (e.g.REGISTRATION_ENABLED=false).404 Not Found— resource doesn't exist, or exists but isn't owned by the caller (ownership is enforced by aWHERE ... AND user_id = $N/account_idjoin on every query — a foreign id you don't own reads as 404, not 403).400 Bad Request— missing/invalid required fields.409 Conflict— duplicate unique constraint (e.g. registering an email that already exists).500 Internal Server Error—{ error: String(err) }, generally an upstream IMAP/SMTP/DB failure.
No rate limiting yet on Bearer keys — a single key can drive as much traffic as the underlying IMAP/SMTP servers allow.
Two different kinds of id appear in these routes — don't confuse them:
- Database UUID — for rows Synapmail owns (
accounts,rules,templates,signatures,contacts,api-keys,scheduled,pgp/contacts). Stable, assigned at creation. - IMAP UID — for anything that is a message (
messages/[id],messages/[id]/snooze,messages/[id]/mdn,messages/[id]/attachment/[partId]). Scoped to one(account, folder)pair — the same UID in a different folder is a different message, which is why these routes also requireaccountandfolderquery params.
List the caller's email accounts, each with its authoritative INBOX unread count.
Response { data: EmailAccount[] } where each account also carries unreadCount: number (from mailbox_stats, falling back to a live cache count — see CLAUDE.md's IMAP section).
interface EmailAccount {
id: string; name: string; email: string
imapHost: string; imapPort: number; imapSecure: boolean
smtpHost: string; smtpPort: number; smtpSecure: boolean
username: string; isDefault: boolean; color: string
oauthProvider: 'microsoft' | null
createdAt: string
unreadCount: number
}Add an IMAP/SMTP account.
Body
{
name: string; email: string
imapHost: string; imapPort?: number /* default 993 */; imapSecure?: boolean /* default true */
smtpHost: string; smtpPort?: number /* default 587 */; smtpSecure?: boolean /* default false */
username: string; password: string // plaintext in transit, AES-256-GCM at rest
isDefault?: boolean; color?: string /* default '#6366f1' */
}name, email, imapHost, smtpHost, username, password are required (400 otherwise). Setting isDefault: true clears the flag on every other account first. Returns 201 with the created row (no password/passwordEncrypted field).
Partial update — any subset of the POST body fields. Only fields present in the body are updated (undefined fields are left alone). A non-empty password re-encrypts and replaces password_encrypted. 404 if the account isn't owned by the caller. 400 Nothing to update if the body has no recognized fields.
{ success: true }, or 404 if not owned.
Connectivity check, used by the account wizard before saving. Doesn't touch the DB.
Body { imapHost, imapPort?, imapSecure?, smtpHost, smtpPort?, smtpSecure?, username, password }
Response
{
imap: { ok: boolean; error: string }
smtp: { ok: boolean; error: string }
}⚠ non-standard envelope (no { data } wrapper) — always 200, check .imap.ok / .smtp.ok.
Redirects to Microsoft's consent screen. Sets a CSRF state in an httpOnly cookie (ms_oauth_state, 10 min TTL). No JSON response — a 302 redirect.
OAuth2 callback. Validates state against the cookie, exchanges code for tokens (lib/msOAuth.ts), then either updates an existing account matching the returned email or creates a new one (Live / Outlook, outlook.office365.com:993 / smtp-mail.outlook.com:587, oauthProvider: 'microsoft'). Always redirects to /settings/accounts?success=microsoft or /settings/accounts?error=<reason> — never returns JSON. Not meant to be called directly; it's the browser redirect target from the flow above.
Lists the IMAP folder tree for one account (defaults to the caller's default account if account is omitted). Special-use folders (inbox/sent/drafts/spam/trash) are detected via RFC 6154 flags first, then a localized (FR/EN) name/path regex fallback, and sorted first in that order; Outlook system folders (Sync Issues, Conflicts, Outbox, Calendar, …) are filtered out entirely.
Response { data: FolderInfo[] }
interface FolderInfo {
name: string; path: string
special: 'inbox' | 'sent' | 'drafts' | 'spam' | 'trash' | null
unreadCount: number // authoritative SEARCH UNSEEN via mailbox_stats, falls back to cached-row count
}Returns { data: [] } (not an error) if the account has no folders synced yet or doesn't exist.
Paginated list for one folder. Live IMAP fetch (with messages_cache reconciliation on page 1 — see CLAUDE.md's IMAP section), not a DB-only read.
Query params: account (id, optional — defaults to the default account), folder (default INBOX), page (default 1), perPage (default 30), filter (all | unread | starred, default all).
Response ⚠ non-standard envelope — bare object, not { data }:
{ messages: Message[]; total: number }
// on IMAP failure: { error: string; messages: []; total: 0 }, status 500
// on "no account configured": { messages: []; total: 0, error: 'No account configured' }, status 200Each Message also carries accountId. Snoozed messages (snoozed_messages, not yet woken) are filtered out and total is adjusted accordingly.
interface Message {
uid: string; messageId: string
from: { name: string; address: string }
to: { name: string; address: string }[]
cc?: { name: string; address: string }[]
replyTo?: { name: string; address: string }
subject: string; date: string; preview: string
isRead: boolean; isStarred: boolean; isFlagged: boolean
hasAttachments: boolean; threadId?: string
folder: string; accountId: string
bodyHtml?: string; bodyPlain?: string
attachments?: { id: string; filename: string; contentType: string; size: number }[]
listUnsubscribe?: string
authResults?: { spf: 'pass'|'fail'|'none'; dkim: 'pass'|'fail'|'none'; dmarc: 'pass'|'fail'|'none' }
dispositionNotificationTo?: string
size?: number; xPriority?: number
}Full message (headers + body + attachments metadata), by IMAP UID. account is required (400 if missing). 404 if the account isn't owned, or the message doesn't exist in that folder.
Response ⚠ non-standard envelope — the Message object directly (spread with accountId), not { data }.
Mark read/unread and/or starred. account required.
Body { isRead?: boolean; isStarred?: boolean } — either or both. { success: true }.
Deletes (IMAP \Deleted + expunge). account required. { success: true }.
Mark read/unread, or move, a set of messages in one call.
Body
{
uids: string[]; accountId: string; folder: string
action: 'read' | 'unread' | 'move'
destination?: string // required when action === 'move'
}400 if uids is empty or action/accountId/folder missing, or destination missing for move. { success: true }.
Body { uids: string[]; accountId: string; folder: string } → { success: true }.
Streams one attachment by its index in the parsed MIME structure (partId, 0-based). inline=true sets Content-Disposition: inline (for preview); omitted/false forces download. Not a JSON route — returns the raw bytes with Content-Type/Content-Disposition/Content-Length headers, or a plain-text error body with the matching status (400/404/500) — not { error } JSON.
Sends an RFC 8098 Message Disposition Notification ("read receipt") for a message that requested one (Disposition-Notification-To header present).
Body { accountId: string; folder: string }. 400 if the message has no dispositionNotificationTo. { success: true }.
Hides a message from GET /api/messages and the focus list until until.
Body { until: string /* ISO date, must be future */; folder: string; accountId: string; subject?: string; fromAddress?: string; fromName?: string }
400 if until/folder/accountId missing or until isn't a future date. 404 if the account isn't owned. Upserts on (account_id, folder, uid). Response { success: true; until: string }.
Un-snoozes (moves the message back to the visible list immediately). { success: true }.
Full-text IMAP search (subject/from/body, server-side SEARCH) in one folder. folder defaults to INBOX. Requires q.length >= 2, else returns { messages: [] } immediately (not an error).
Response ⚠ non-standard envelope — { messages: Message[] } (accountId added to each), or { messages: [], error } on IMAP failure.
Groups messages by normalized subject (strips Re:/Fwd:/Rép:/TR:/AW:/SV:/VS: prefixes recursively, case-insensitively), sorted oldest→newest. Used to render a conversation thread. Requires subject, ≥2 chars after normalization.
Response ⚠ non-standard envelope — { messages: Message[] }.
Send (or reply/forward) immediately.
Body
{
accountId: string; to: string | string[]; subject: string
cc?: string | string[]; bcc?: string | string[]
html?: string; text?: string
inReplyTo?: string; references?: string
requestReadReceipt?: boolean // injects a 1×1 tracking pixel into `html` + Disposition-Notification-To
}accountId, to, subject required. On send: appends a copy to the account's IMAP Sent folder (fire-and-forget), extracts to+cc as contacts (fire-and-forget, lib/contacts.ts), and if requestReadReceipt is set, records a sent_tracking row keyed by a fresh UUID token embedded in the pixel URL (GET /api/track/[token]). Response { success: true }. Note: forwarded-attachment resolution (by IMAP descriptor) is handled by the legacy /api/send route, not this one — see Legacy routes.
Lists pending (not-yet-woken) snoozes for the caller, optionally scoped to one account. Ordered by wake time ascending, capped at 50. The scheduler (lib/scheduler.ts) deletes expired rows every 60s — a snooze disappears from this list (and the message reappears in /api/messages) automatically once it wakes.
Response
{
data: {
uid: string; accountId: string; folder: string
subject: string; fromAddress: string | null; fromName: string | null
snoozeUntil: string
}[]
}One compose draft per (user, account) — reply/forward/replyAll never persist a draft, only plain compose.
{ data: Draft | null } where
interface Draft {
to_addresses: string[]; cc_addresses: string[]; bcc_addresses: string[]
subject: string; body_html: string
}400 if accountId missing.
Upsert. Body { accountId: string; to?: string[]; cc?: string[]; bcc?: string[]; subject?: string; content?: string }. { success: true }.
{ success: true }.
Lists the caller's pending scheduled sends, soonest first.
Response
{
data: {
id: string; account_id: string
to_addresses: string; cc_addresses: string | null; bcc_addresses: string | null // JSON-encoded string arrays
subject: string; send_at: string; status: 'pending' | 'sent' | 'failed'; created_at: string
}[]
}Queues a send for a future time; picked up by lib/scheduler.ts's 60s sweep (SELECT ... FOR UPDATE SKIP LOCKED).
Body
{
accountId?: string // defaults to the caller's default account
to: string[]; subject: string; sendAt: string // ISO date, must be future
cc?: string[]; bcc?: string[]; html?: string; inReplyTo?: string
forwardedAttachments?: { uid: string; accountId: string; folder: string; partIdx: number; filename: string; contentType: string }[]
}400 if to/subject/sendAt missing or sendAt isn't in the future. Response { data: { id: string } }.
Cancels — only while still pending (a race with the scheduler picking it up first returns 404 Not found or already sent). { data: { id: string } }.
Auto-extracted from sent/received mail (lib/contacts.ts), plus manually-added entries.
Query params: q (fuzzy name/email match, default empty = all), limit (default 8, capped at 50), all (true bypasses the frequency >= 2 OR is_manual filter — used by the Settings page's full list), sort (score default | name | frequency | recent), account (id — restricts to contacts seen via messages_cache.from_address on that account).
score sort = frequency*0.5 + recency-decay*35 + (bidirectional bonus 15), starred always first.
Response { data: Contact[] }
interface Contact {
id: string; name: string; email: string; frequency: number
sentCount: number; receivedCount: number; lastContactAt: string
isStarred: boolean; isManual: boolean; notes: string | null; createdAt: string
}Manually add/upsert a contact. Body { email: string; name?: string; notes?: string }. email required and validated by regex (400 email invalide otherwise). On conflict (existing email for this user), merges: keeps the existing name if name is blank, sets isManual: true. Response 201 { data: { id: string } }.
Body { name?: string; notes?: string; isStarred?: boolean } — any subset. 400 if name is provided but empty. { success: true }.
{ success: true }.
Bulk-cleans low-signal contacts: frequency < 2 AND is_manual = false AND is_starred = false. 400 without the oneshots=true param (safety — prevents an accidental bare DELETE /api/contacts). Response { data: { deleted: number } }.
{ data: EmailRule[] }, optionally filtered to one account client-side.
type RuleField = 'from'|'to'|'cc'|'subject'|'body'|'has_attachments'|'list_unsubscribe'|'size'|'date_received'|'priority'|'header'
type RuleOperator = 'contains'|'not_contains'|'equals'|'not_equals'|'starts_with'|'ends_with'|'is_true'|'is_false'|'greater_than'|'less_than'|'before'|'after'
type RuleActionType = 'move'|'mark_read'|'mark_unread'|'mark_starred'|'mark_unstarred'|'delete'|'forward'
interface EmailRule {
id: string; userId: string; accountId: string; name: string; enabled: boolean; priority: number
conditionLogic: 'all' | 'any'
conditions: { id: string; field: RuleField; operator: RuleOperator; value: string; headerName?: string }[]
actions: { id: string; type: RuleActionType; value?: string }[]
stopProcessing: boolean; createdAt: string; updatedAt: string
lastRunAt?: string | null; totalProcessed?: number; totalMatched?: number
}Body { accountId: string; name: string; conditions: RuleCondition[]; actions: RuleAction[]; enabled?: boolean; conditionLogic?: 'all'|'any'; stopProcessing?: boolean }. Requires a non-empty name, at least one condition, at least one action, and an owned accountId (400/404 otherwise). New rule gets priority = max(existing) + 1 for that account. 201 { data: EmailRule }.
{ data: EmailRule } or 404.
Body: any subset of EmailRule fields. { data: EmailRule } or 404.
{ data: { deleted: true } } or 404.
Dry run — evaluates the rule against real messages without applying any action.
Body { folder?: string /* default INBOX */; limit?: number /* default 50, capped 200 */ }
Response
{ data: { matched: { uid, from, subject, date, isRead, folder }[]; total: number; scanned: number } }Applies all enabled rules for one account against a folder page, immediately (outside the scheduler's normal 5-min cycle).
Body { accountId: string; folder?: string /* default INBOX */; page?: number /* default 1 */; perPage?: number /* default 50, capped 200 */ }
Response { data: { processed: number; matched: number; results: RuleExecutionResult[] } }
Query ?account=<id> (required). Body { rules: Partial<EmailRule>[] } (as produced by the export below). Skips entries missing name/conditions/actions. Response 201 { data: { imported: number; rules: EmailRule[] } }.
Downloads all of the caller's rules as JSON (Content-Disposition: attachment). Not { data } — raw { version, exportedAt, rules } file body.
Downloads the equivalent Sieve script (.sieve file download), optionally scoped to one account. Plain text body, not JSON.
{ data: ComposeTemplate[] } where ComposeTemplate = { id, userId, name, subject, contentHtml, createdAt }.
Body { name: string; subject?: string; contentHtml?: string }. 400 if name blank. 201 { data: ComposeTemplate }.
Body: any subset of { name, subject, contentHtml } (unset fields keep their current value via COALESCE). { data: ComposeTemplate } or 404.
{ success: true }.
{ data: Signature[] } where Signature = { id, userId, accountId: string | null, name, contentHtml, isDefault } (accountId: null = usable with any account).
Body { name: string; contentHtml?: string; isDefault?: boolean; accountId?: string | null }. 400 if name missing. Setting isDefault: true clears the flag on the caller's other signatures first. 201 { data: Signature }.
Body: any subset of the POST fields (COALESCE-merged). { data: Signature } or 404.
{ success: true }.
Server-side storage is public keys only — the private key never leaves the browser (see CLAUDE.md's PGP section).
The caller's own published public key. { data: PgpIdentity | null } where PgpIdentity = { userId, fingerprint, armoredPublicKey, createdAt, updatedAt }.
Publishes/updates the caller's own public key (called once the browser generates a keypair). Body { fingerprint: string; armoredPublicKey: string }, both required. { data: PgpIdentity }.
Without emails: all of the caller's imported contact keys, sorted by email. With emails (comma-separated): only those matching (case-insensitive) — this is what ComposeModal polls to decide whether the "Encrypt" toggle can be shown for the current recipients.
Response { data: PgpContactKey[] } where PgpContactKey = { id, userId, email, name: string | null, fingerprint, armoredKey, createdAt }.
Import a contact's public key. Body { email: string; armoredKey: string; fingerprint: string; name?: string }, all three required; armoredKey must contain -----BEGIN PGP PUBLIC KEY BLOCK----- (400 otherwise). Upserts on (user_id, email). 201 { data: PgpContactKey }.
{ success: true }.
Manage the Bearer keys documented in Authentication above. This management surface is itself session-only — you can't mint or revoke keys using a key.
{ data: ApiKey[] } (active keys only — revoked ones are excluded), where ApiKey = { id, name, keyPrefix, lastUsedAt: string | null, createdAt, requestCount24h: number }. Never includes the raw key or its hash. requestCount24h is a live COUNT over api_key_requests in the last 24h (see the logs endpoint below).
Body { name: string }, required. Generates syn_<48 hex chars>, stores only its SHA-256 hash + 12-char prefix. Response 201 { data: ApiKey & { key: string } } — key is the only time the raw value is ever returned; it is not retrievable again.
Soft-revoke (revoked_at = NOW()) — the key stops authenticating immediately. { success: true } (idempotent — succeeds even if the id doesn't belong to the caller or doesn't exist, since the UPDATE predicate just matches zero rows).
Per-key request log — every successful Bearer authentication against this key (not session-cookie requests) is logged fire-and-forget by authenticate() (lib/apiAuth.ts): method, path, IP (X-Forwarded-For/X-Real-IP), timestamp. Does not log the response status or body — only that a request came in and was authenticated. limit defaults to 50, capped at 200. 404 if the key id isn't owned by the caller. Rows older than 30 days are purged automatically every 6h (lib/scheduler.ts → processApiKeyLogCleanup) — this is an audit trail, not permanent storage.
Response { data: ApiKeyRequestLog[] }, newest first:
interface ApiKeyRequestLog {
id: string; method: string; path: string
ipAddress: string | null; createdAt: string
}One aggregation call (~15 parallel SQL queries) backing the /dashboard command center. account (optional) narrows every widget except the account list and the two contact-derived widgets (contacts carry no account_id in the schema). An account id you don't own is silently dropped (treated as "all accounts"), never a 404.
Response { data: DashboardData } — see types/dashboard.ts for the full shape:
interface DashboardData {
accountFilter: string | null // echoes back the account id actually applied, or null
kpis: {
unreadTotal: number; unreadToday: number; sentToday: number
trackedOpens7d: number; trackedOpensToday: number
scheduledPending: number; nextScheduledAt: string | null
}
accounts: { id: string; name: string; email: string; color: string; unread: number }[]
activity: { date: string /* YYYY-MM-DD */; received: number; sent: number }[] // 14 days, zero-filled
focus: {
uid: string; accountId: string; accountName: string; accountColor: string; folder: string
subject: string; fromName: string | null; fromAddress: string | null; date: string
reason: 'invoice'|'deadline'|'reply'|'vip'|'frequent'|'starred'|'attachment'
}[] // top 5 by heuristic score
receipts: { subject: string | null; sentTo: string; openedAt: string; openCount: number; accountName: string | null; accountColor: string | null }[] // last 6 opened
scheduled: { id: string; subject: string; to: string[]; sendAt: string; accountName: string | null; accountColor: string | null }[] // next 6
rules: {
items: { id: string; name: string; enabled: boolean; matched7d: number }[] // top 6 by 7-day match count
activeCount: number; actions7d: number
}
followUps: { name: string; email: string; frequency: number; lastContactAt: string }[] // top 5 contacts not seen in 10+ days
}The same "à traiter" heuristic ranking as the dashboard's focus widget (lib/focus.ts → scoreFocus), but as a single scoped query — used by ReadingPane's empty state, not the full dashboard aggregation.
Response { data: FocusItem[] } (same shape as DashboardData.focus, top 5).
Returns the caller's row from user_settings, or hard-coded defaults if none exists yet (first login).
Response { data: UserSettings }
interface UserSettings {
theme: string; language: string; messages_per_page: number
thread_view: boolean; reading_pane: boolean; notifications: boolean
undo_send_delay: number; start_view: 'inbox' | 'dashboard'
active_account_id: string | null; sidebar_collapsed: boolean
mail_density: 'comfortable' | 'compact'; list_width: number
dashboard_account_id: string | null
}Defaults: theme: 'system', language: 'fr', messages_per_page: 30, thread_view: true, reading_pane: true, notifications: true, undo_send_delay: 10, start_view: 'inbox', active_account_id: null, sidebar_collapsed: false, mail_density: 'comfortable', list_width: 320, dashboard_account_id: null.
Body: any subset of the fields above (snake_case keys, matching the DB columns — not camelCase). Unrecognized keys are silently ignored; 400 No valid fields if the body has none of the allowed keys. UPSERTs, then returns the full row.
Response { data: UserSettings } (full, post-update).
{ data: { id, name, email, role, avatar_url: string | null } }.
Body { name?: string; currentPassword?: string; newPassword?: string }. Changing the password requires currentPassword to match (bcrypt-compared) or returns 400 Mot de passe actuel incorrect. { data: { id, name, email, role } }.
Requires role = 'admin' on the caller (checked per-request against the DB, not just the JWT) — 403 Forbidden otherwise.
{ data: { id, email, name, role, createdAt }[] }, newest first.
Create a user directly (bypasses REGISTRATION_ENABLED). Body { name: string; email: string; password: string; role?: 'admin' | 'user' /* default user */ }. 201 { data: User }.
Change role. Body { role: 'admin' | 'user' }. 400 Invalid role for any other value. 404 if the target id doesn't exist. { data: User }.
400 Cannot delete your own account if id is the caller's own id. { success: true }.
Session-only, per-user AI configuration (ai_settings table) supporting a self-hosted Ollama endpoint or an OpenAI-compatible API key.
Response { data: { provider, hasApiKey: boolean, baseUrl, model, systemPrompt, featureSummarize, featureReplyDraft, featureImprove, featureTranslate, configured: boolean } }. hasApiKey reports presence only — the encrypted key itself is never returned. Defaults when unconfigured: provider: 'ollama', baseUrl: 'http://localhost:11434', model: 'llama3', every feature flag true, configured: false.
Body { provider?: string; apiKey?: string; baseUrl?: string; model?: string; systemPrompt?: string; featureSummarize?: boolean; featureReplyDraft?: boolean; featureImprove?: boolean; featureTranslate?: boolean }. apiKey, if present, is AES-256-GCM encrypted before storage; omit it to keep the existing key (ON CONFLICT preserves the old api_key_encrypted when the new value is null). { data: { success: true } }.
Probes common local network locations (localhost, host.docker.internal, ollama, detected Docker gateway IPs) for a running Ollama instance (GET /api/tags, 2s timeout each, tried in parallel). Used by the AI settings page's "auto-detect" button.
Response { data: { found: boolean; url: string | null; models: string[] } }.
Runs one AI transformation against arbitrary text, using the caller's configured provider. 400 AI not configured if ai_settings has no row for the user yet.
Body
{
action: 'summarize' | 'reply' | 'improve' | 'tone' | 'translate'
content: string // HTML is stripped server-side before prompting
context?: string // 'reply' only — extra instruction
tone?: 'formal' | 'casual' | 'assertive' | 'concise' | 'empathetic' // 'tone' only, default 'formal'
targetLang?: 'en' | 'fr' // 'translate' only
}Response { data: { result: string } }, or 500 { error } on an upstream AI provider failure.
The read-receipt pixel target, embedded as an <img> in sent HTML mail when requestReadReceipt was set. Returns a 1×1 transparent GIF unconditionally (even for an unknown token) and, non-blockingly, records the first open time + increments open_count + captures IP/user-agent on sent_tracking. Not a JSON route.
Batch lookup of tracking state by subject (not message id — works around Outlook's message-ID rewriting), |||-separated. Only the most recent tracking row per subject is returned.
Response { data: Record<string, { opened: boolean; openedAt: string | null; openCount: number }> } — keyed by subject; subjects with no matching record are simply absent from the map.
Sends a mailto: unsubscribe email via the account's own SMTP (for List-Unsubscribe headers that specify a mailto target). Body { accountId: string; to: string; subject?: string /* default 'unsubscribe' */ }. Response { data: { success: true } }.
Server-Sent Events. Keeps the connection open; sends { type: 'connected', userId } immediately, then { type: 'ping' } every 25s, plus (filtered to the caller's own userId) { type: 'scheduled_sent', subject, to } when lib/scheduler.ts sends a queued email, and { type: 'rule_applied', ruleName, matched, folder } when a background rule run matches something. Does not poll IMAP for new mail itself — see CLAUDE.md's SSE section for what does. Not a request/response route — consume with EventSource.
Creates a user. Disabled entirely (403 Registration is disabled) once REGISTRATION_ENABLED=false. Body { name: string; email: string; password: string /* min 8 chars */ }. The very first user ever created gets role: 'admin' automatically; everyone after gets role: 'user'. 409 Email already registered on a duplicate. 201 { data: { id, email, name, role } }.
Standard NextAuth credentials-provider routes (/api/auth/signin, /api/auth/callback/credentials, /api/auth/session, /api/auth/signout, /api/auth/csrf, etc.) — not hand-written, not part of this route-by-route reference. See Auth.js docs for the standard shapes.
These predate (or duplicate) a newer Bearer-eligible equivalent and aren't part of the documented Bearer surface. They still work, are session-only, and aren't deprecated in code — just superseded for external/agent use:
DB-only full-text search over messages_cache (subject/from/preview ILIKE) across all of the caller's accounts at once — unlike GET /api/messages/search, which does a live per-account IMAP SEARCH. Faster but stale (bounded by cache freshness) and IMAP body text isn't searched. q.length < 2 → { data: [] }. Response { data: CachedMessageRow[] } (raw messages_cache columns, snake_case, plus account_id).
Same job as POST /api/messages/send (SMTP send, forwarded-attachment resolution from IMAP), but session-only and without the Sent-folder append, contact extraction, or read-receipt tracking that /api/messages/send does. Prefer /api/messages/send for anything new. Body: same shape as /api/messages/send, plus from?: string (override the account's own address) and forwardedAttachments (resolved server-side from IMAP by { uid, accountId, folder, partIdx } descriptors — see CLAUDE.md's "Forwarded attachments" section). { success: true }.
Fetches recent GitHub Releases for the "new version available" banner (gh release create on this repo — see the project's release memory). Server-cached 1h. Response { data: { releases: GitHubRelease[]; current: string } } where current comes from NEXT_PUBLIC_APP_VERSION. 502 if the GitHub API is unreachable.
KEY="syn_..."
BASE="https://your-instance/api"
# List accounts
curl -s -H "Authorization: Bearer $KEY" "$BASE/accounts" | jq
# List the last 10 inbox messages of the default account
curl -s -H "Authorization: Bearer $KEY" "$BASE/messages?perPage=10" | jq '.messages[] | {subject, from, isRead}'
# Send a plain-text email
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"accountId":"<uuid>","to":["dest@example.com"],"subject":"Hello","text":"Hi from the API"}' \
"$BASE/messages/send"