Skip to content

Latest commit

 

History

History
674 lines (481 loc) · 37.3 KB

File metadata and controls

674 lines (481 loc) · 37.3 KB

Synapmail — API Reference

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.

Base URL

https://<your-instance>/api

Response envelope

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 }.

Authentication

Two ways in, both handled transparently by route handlers that call authenticate() (lib/apiAuth.ts):

  1. Session cookie — the normal browser login (Auth.js v5). Used by the app itself and by every route not marked Bearer below.

  2. 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 like syn_<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.

Errors

  • 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 a WHERE ... AND user_id = $N / account_id join 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.

Identifiers

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 require account and folder query params.

Accounts

GET /api/accounts 🔑 Bearer

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
}

POST /api/accounts — session only

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).

PATCH /api/accounts/[id] — session only

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.

DELETE /api/accounts/[id] — session only

{ success: true }, or 404 if not owned.

POST /api/accounts/test — session only

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.


OAuth (Microsoft)

GET /api/oauth/microsoft — session only

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.

GET /api/oauth/microsoft/callback — session only

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.


Folders

GET /api/folders?account=<id> 🔑 Bearer

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.


Messages

GET /api/messages?account=&folder=&page=&perPage=&filter= 🔑 Bearer

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 200

Each 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
}

GET /api/messages/[id]?account=&folder= 🔑 Bearer

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 }.

PATCH /api/messages/[id]?account=&folder= 🔑 Bearer

Mark read/unread and/or starred. account required.

Body { isRead?: boolean; isStarred?: boolean } — either or both. { success: true }.

DELETE /api/messages/[id]?account=&folder= 🔑 Bearer

Deletes (IMAP \Deleted + expunge). account required. { success: true }.

PATCH /api/messages/bulk 🔑 Bearer

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 }.

DELETE /api/messages/bulk 🔑 Bearer

Body { uids: string[]; accountId: string; folder: string } → { success: true }.

GET /api/messages/[id]/attachment/[partId]?account=&folder=&inline= — session only

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.

POST /api/messages/[id]/mdn — session only

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 }.

POST /api/messages/[id]/snooze — session only

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 }.

DELETE /api/messages/[id]/snooze?account=&folder= — session only

Un-snoozes (moves the message back to the visible list immediately). { success: true }.

GET /api/messages/search?q=&folder=&account= 🔑 Bearer

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.

GET /api/messages/thread?subject=&folder=&account= 🔑 Bearer

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[] }.

POST /api/messages/send 🔑 Bearer

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.


Snoozed messages

GET /api/snoozed?account= — session only

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
  }[]
}

Drafts

One compose draft per (user, account) — reply/forward/replyAll never persist a draft, only plain compose.

GET /api/drafts?accountId= — session only

{ data: Draft | null } where

interface Draft {
  to_addresses: string[]; cc_addresses: string[]; bcc_addresses: string[]
  subject: string; body_html: string
}

400 if accountId missing.

PUT /api/drafts — session only

Upsert. Body { accountId: string; to?: string[]; cc?: string[]; bcc?: string[]; subject?: string; content?: string }. { success: true }.

DELETE /api/drafts?accountId= — session only

{ success: true }.


Scheduled emails

GET /api/scheduled — session only

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
  }[]
}

POST /api/scheduled — session only

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 } }.

DELETE /api/scheduled/[id] — session only

Cancels — only while still pending (a race with the scheduler picking it up first returns 404 Not found or already sent). { data: { id: string } }.


Contacts

Auto-extracted from sent/received mail (lib/contacts.ts), plus manually-added entries.

GET /api/contacts?q=&limit=&all=&sort=&account= 🔑 Bearer

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
}

POST /api/contacts — session only

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 } }.

PATCH /api/contacts/[id] — session only

Body { name?: string; notes?: string; isStarred?: boolean } — any subset. 400 if name is provided but empty. { success: true }.

DELETE /api/contacts/[id] — session only

{ success: true }.

DELETE /api/contacts?oneshots=true — session only

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 } }.


Rules (email filters)

GET /api/rules?account= — session only

{ 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
}

POST /api/rules — session only

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 }.

GET /api/rules/[id] — session only

{ data: EmailRule } or 404.

PATCH /api/rules/[id] — session only

Body: any subset of EmailRule fields. { data: EmailRule } or 404.

DELETE /api/rules/[id] — session only

{ data: { deleted: true } } or 404.

POST /api/rules/[id]/test — session only

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 } }

POST /api/rules/run — session only

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[] } }

POST /api/rules/import — session only

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[] } }.

GET /api/rules/export — session only

Downloads all of the caller's rules as JSON (Content-Disposition: attachment). Not { data } — raw { version, exportedAt, rules } file body.

GET /api/rules/sieve?account= — session only

Downloads the equivalent Sieve script (.sieve file download), optionally scoped to one account. Plain text body, not JSON.


Templates

GET /api/templates — session only

{ data: ComposeTemplate[] } where ComposeTemplate = { id, userId, name, subject, contentHtml, createdAt }.

POST /api/templates — session only

Body { name: string; subject?: string; contentHtml?: string }. 400 if name blank. 201 { data: ComposeTemplate }.

PATCH /api/templates/[id] — session only

Body: any subset of { name, subject, contentHtml } (unset fields keep their current value via COALESCE). { data: ComposeTemplate } or 404.

DELETE /api/templates/[id] — session only

{ success: true }.


Signatures

GET /api/signatures — session only

{ data: Signature[] } where Signature = { id, userId, accountId: string | null, name, contentHtml, isDefault } (accountId: null = usable with any account).

POST /api/signatures — session only

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 }.

PATCH /api/signatures/[id] — session only

Body: any subset of the POST fields (COALESCE-merged). { data: Signature } or 404.

DELETE /api/signatures/[id] — session only

{ success: true }.


PGP end-to-end encryption

Server-side storage is public keys only — the private key never leaves the browser (see CLAUDE.md's PGP section).

GET /api/pgp/me — session only

The caller's own published public key. { data: PgpIdentity | null } where PgpIdentity = { userId, fingerprint, armoredPublicKey, createdAt, updatedAt }.

PUT /api/pgp/me — session only

Publishes/updates the caller's own public key (called once the browser generates a keypair). Body { fingerprint: string; armoredPublicKey: string }, both required. { data: PgpIdentity }.

GET /api/pgp/contacts?emails=<a@x.com,b@y.com> — session only

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 }.

POST /api/pgp/contacts — session only

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 }.

DELETE /api/pgp/contacts/[id] — session only

{ success: true }.


API keys

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.

GET /api/api-keys — session only

{ 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).

POST /api/api-keys — session only

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.

DELETE /api/api-keys/[id] — session only

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).

GET /api/api-keys/[id]/logs?limit= — session only

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
}

Dashboard & focus

GET /api/dashboard?account= — session only

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
}

GET /api/focus?account= — session only

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).


Settings

GET /api/settings — session only

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.

PATCH /api/settings — session only

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).


Profile

GET /api/profile — session only

{ data: { id, name, email, role, avatar_url: string | null } }.

PATCH /api/profile — session only

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 } }.


Admin 👑

Requires role = 'admin' on the caller (checked per-request against the DB, not just the JWT) — 403 Forbidden otherwise.

GET /api/admin/users — 👑 Admin

{ data: { id, email, name, role, createdAt }[] }, newest first.

POST /api/admin/users — 👑 Admin

Create a user directly (bypasses REGISTRATION_ENABLED). Body { name: string; email: string; password: string; role?: 'admin' | 'user' /* default user */ }. 201 { data: User }.

PATCH /api/admin/users/[id] — 👑 Admin

Change role. Body { role: 'admin' | 'user' }. 400 Invalid role for any other value. 404 if the target id doesn't exist. { data: User }.

DELETE /api/admin/users/[id] — 👑 Admin

400 Cannot delete your own account if id is the caller's own id. { success: true }.


AI assist

Session-only, per-user AI configuration (ai_settings table) supporting a self-hosted Ollama endpoint or an OpenAI-compatible API key.

GET /api/ai/settings — session only

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.

PATCH /api/ai/settings — session only

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 } }.

GET /api/ai/detect — session only

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[] } }.

POST /api/ai/action — session only

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.


Tracking, unsubscribe & realtime

GET /api/track/[token] — public, no auth

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.

GET /api/track/status?accountId=&subjects=<a|||b|||c> — session only

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.

POST /api/unsubscribe — session only

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 } }.

GET /api/stream — session only

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.


Auth & registration

POST /api/register — public, no auth

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 } }.

/api/auth/[...nextauth] — Auth.js v5 handler

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.


Legacy / internal routes

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:

GET /api/search?q= — session only

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).

POST /api/send — session only

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 }.

GET /api/updates — public, no auth

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.


Example: minimal Bearer client

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"