{"openapi":"3.1.0","info":{"title":"AutoEmail Agent REST API","version":"2.0.2","summary":"Server-to-server REST API for an external LLM agent to read emails and draft, generate, or send replies.","description":"A **token-authenticated, server-to-server** REST API that lets an external LLM agent (and its\noperator) read a mailbox's emails and either **draft** replies/new emails for human approval or\n**send** them autonomously - scoped to exactly the accounts (\"businesses\") a given API key is\nallowed to touch.\n\n## Mental model\n- You authenticate with a single **Bearer API key** (`ak_live_...`). One key = one identity =\n  one fixed **allowlist of accounts** + one **mode**.\n- Start every session with `GET /me` to learn the key's `mode` and its `allowedAccounts`.\n- `GET /emails` and `GET /emails/{id}` are **read** operations scoped to the allowlist.\n- `POST /emails/{id}/reply` and `POST /emails/send` are **write** operations whose effect depends\n  on the key's mode and request copy mode (see below).\n\n## The two modes (what \"send\" actually does)\nThe mode is fixed per key and decides whether a write **sends** or only **drafts**:\n\n| Mode | `reply` / `send` effect |\n|------|-------------------------|\n| `human_in_the_loop` (secure default) | **Never sends.** Creates a `pending` email + draft that appears in the dashboard approval queue for a human to review/send. Returns `201 { \"status\": \"pending_approval\", ... }`. A HITL key **cannot** be coerced into an immediate send. |\n| `full_autonomous` | In final-copy mode, sends immediately over SMTP, marks the email `sent`, and returns `200 { \"status\": \"sent\", ... }` unless `POST /emails/{id}/reply` passes `\"send\": false`. In generated-copy mode, AutoEmail writes the copy first and sends only when `\"send\": true`; omitted/`false` queues a draft. |\n\n## Request copy modes\n- Omit `mode` or set `mode: \"final\"` when the caller provides final `subject`/`body` copy.\n- Set `mode: \"generate\"` when AutoEmail should write the copy using the configured OpenRouter text\n  model, global prompt, business knowledge base, and learned lessons. Generated mode returns a\n  `generated` object. It queues a draft by default, even for `full_autonomous` keys, and sends only\n  when `send: true` is explicit.\n\n## Per-key account allowlist (scoping semantics)\n- Reads/writes are restricted to the key's allowlisted accounts. Other accounts are invisible.\n- An email that exists but belongs to a **non-allowlisted** account returns **`404`** (never `403`)\n  - the API never reveals the existence of resources outside the allowlist.\n- Naming a specific `businessId` that is **not** in the allowlist returns **`403`**.\n- Ownership is **re-verified server-side on every operation**; the stored allowlist alone is never trusted.\n\n## Not for browsers\nThis API is **CORS-closed on purpose**: no `Access-Control-Allow-*` headers and no `OPTIONS`\nhandlers. Call it from a backend only. The API key must never reach client-side JavaScript.\n\n## Keys\nKeys are issued in the app (Settings -> API Keys) and look like `ak_live_<random>`. The server\nstores **only a SHA-256 hash**; the plaintext is shown **exactly once** at creation and can never\nbe retrieved again. If lost, revoke and create a new one.\n\n## Conventions\n- `200` - read completed, or an autonomous send completed.\n- `201` - a draft / pending item was created for human approval.\n- All errors share the envelope `{ \"error\": \"<machine_code>\", \"message\": \"<human readable>\" }`.\n  Responses never contain SMTP/IMAP hosts, usernames, passwords, key hashes, or stack traces.\n- Timestamps are Unix epoch **milliseconds** (numbers). Ids are Convex document ids (lowercase\n  alphanumeric strings, e.g. `m3k9q1w...`).\n\n\n## Quota metering (v2)\n\nEvery accepted billable write consumes exactly **1** email-quota unit with fail-fast `402 quota_exceeded` semantics: `POST /emails/send`, `POST /emails/{id}/reply`, `POST /emails/{id}/approve`, and each accepted `POST /outreach/batch` recipient. Read your remaining quota anytime via `GET /usage`. (Before v2, /emails/send and /reply did NOT consume quota - v2 fixes that so all four billable paths share one metering implementation.)\n\n**Refund on failure (v2.0.2):** the unit is metered up front, but it is **refunded** when the operation fails AFTER metering and produces no billable artifact - a send that throws (5xx), an AI-generation failure (`502`), or an in-action `422` (`no_draft`, `invalid_target`). You are billed only for a created `pending` draft/email or an actual send. For `POST /outreach/batch`, an async recipient whose drip send ends in `send_failed` is likewise refunded (its unit produced no mail).","contact":{"name":"AutoEmail","email":"info@autoemail.dev","url":"https://github.com/nicojaroszewski/automail"},"license":{"name":"Proprietary","identifier":"LicenseRef-Proprietary"}},"servers":[{"url":"https://{deployment}.convex.site/api/v1","description":"Convex **HTTP Actions** host. Derive your base URL by taking `NEXT_PUBLIC_CONVEX_URL` and\nswapping `.convex.cloud` -> `.convex.site` (keep the region), then appending `/api/v1`.\nFor example `https://majestic-goat-454.eu-west-1.convex.cloud` becomes\n`https://majestic-goat-454.eu-west-1.convex.site/api/v1`.\n\nUse the `.convex.site` HTTP-actions host, **NOT** `.convex.cloud` (the reactive client/query\nendpoint, which will not route `/api/v1/*`). You can also find the exact value as the\ndeployment's `CONVEX_SITE_URL` / `CONVEX_HTTP_ACTIONS_URL` (Convex dashboard ->\nSettings -> URL & Deploy Key -> \"HTTP Actions URL\").\n","variables":{"deployment":{"default":"majestic-goat-454.eu-west-1","description":"The deployment-and-region subdomain of your Convex HTTP-actions host, e.g. `majestic-goat-454.eu-west-1`."}}}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Discovery","description":"Identify the key and the accounts it may operate on."},{"name":"Read","description":"List and read emails scoped to the key's allowlist."},{"name":"Write","description":"Reply to an inbound email or compose a new outbound email (drafts in HITL, sends in autonomous mode)."},{"name":"Sync","description":"Trigger an IMAP poll for a single mailbox."},{"name":"Threads","description":"Read conversation threads and their message summaries."},{"name":"State","description":"Mutate email state: read/starred flags and status transitions."},{"name":"Drafts","description":"List, approve, and decline reply drafts."},{"name":"Attachments","description":"List attachment metadata and fetch short-lived download URLs."},{"name":"Contacts","description":"CRM contact CRUD (upsert by account + email)."},{"name":"Calendar","description":"Read calendar events (read-only in v2)."},{"name":"Spam","description":"Read and update per-mailbox spam threshold + custom rules."},{"name":"Lessons","description":"Read, add, and delete AI learnings (reply + spam)."},{"name":"Usage","description":"Read current-period quota for self-throttling."}],"paths":{"/me":{"get":{"operationId":"getMe","tags":["Discovery"],"summary":"Get key identity and capabilities","description":"Returns the authenticated key's id, name, `mode`, and the accounts it may operate on. Call\nthis first to discover whether writes will **send** (`full_autonomous`) or **draft for\napproval** (`human_in_the_loop`), and to obtain the `businessId`s usable for filtering and\ncomposing.\n\n**Rate limit:** 60 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":60,"window":"60s","key":"apikey:{keyId}:me"},"responses":{"200":{"description":"Key identity and capabilities.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInfo"},"example":{"keyId":"k17e9q2m4p8x6t0v3b5n7c9d","name":"Support Agent","mode":"human_in_the_loop","allowedAccounts":[{"businessId":"j57a2c8e1f4g6h9k3m5p7q0r","domain":"acme.com","email":"support@acme.com","name":"Acme Support"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/accounts":{"get":{"operationId":"listAccounts","tags":["Discovery"],"summary":"List accounts this key may send from","description":"Returns the businesses (email accounts) the key may read and send from, as a non-secret DTO\n(`businessId`, `domain`, `email`, `name`). SMTP/IMAP hosts, usernames, and passwords are\n**never** included. This is the same account shape returned in `GET /me` under\n`allowedAccounts`.\n\n**Rate limit:** 60 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":60,"window":"60s","key":"apikey:{keyId}:accounts"},"responses":{"200":{"description":"The accounts allowed for this key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountListResponse"},"example":{"accounts":[{"businessId":"j57a2c8e1f4g6h9k3m5p7q0r","domain":"acme.com","email":"support@acme.com","name":"Acme Support"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails":{"get":{"operationId":"listEmails","tags":["Read"],"summary":"List / filter emails","description":"Lists emails across the key's allowlisted accounts (or a single account when `businessId` is\nsupplied), newest first. When no `status` filter is given, trashed emails are excluded.\n\nSearch (`q`) is a case-insensitive substring match over subject, sender name, and recipient\nemail. Pass `outreachBatchId` to fetch ONLY the rows of one outreach batch together with their\n`outreachOutcome` - this is how you poll the per-recipient outcomes of a `send: true` batch\nafter the bulk-send response returns. Results are paginated; `pageSize` is clamped to a maximum\nof 100.\n\n**Rate limit:** 120 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":120,"window":"60s","key":"apikey:{keyId}:emailsList"},"parameters":[{"$ref":"#/components/parameters/BusinessIdQuery"},{"$ref":"#/components/parameters/StatusQuery"},{"$ref":"#/components/parameters/SearchQuery"},{"$ref":"#/components/parameters/OutreachBatchIdQuery"},{"$ref":"#/components/parameters/PageQuery"},{"$ref":"#/components/parameters/PageSizeQuery"}],"responses":{"200":{"description":"A page of emails.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailListResponse"},"example":{"items":[{"_id":"m3k9q1w5e7r2t4y6u8i0o9p1","businessId":"j57a2c8e1f4g6h9k3m5p7q0r","businessName":"Acme Support","senderEmail":"customer@example.com","senderName":"Jane Customer","recipientEmail":"support@acme.com","subject":"Where is my order?","status":"pending","receivedAt":1733140000000,"spamScore":0.02,"isStarred":false,"isRead":false,"isComposed":false,"hasDraft":true,"openCount":0,"clickCount":0,"sentAt":null,"lastEventAt":null}],"page":1,"pageSize":20,"total":1,"totalPages":1}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/search":{"get":{"operationId":"searchEmails","tags":["Read"],"summary":"Full-text email search","description":"Relevance-ranked full-text search across the key's allowlisted accounts (or a single account\nwhen `businessId` is supplied). Backed by a real search index over a combined field\n(subject + sender name + sender email + recipient + plain-text body), NOT a substring scan.\n\nResults are lean summary DTOs and NEVER include the full `body` or any HTML - only a short\nleading `snippet` (~140 chars). Fetch `GET /emails/{id}` for the full body.\n\n`status` and `hasAttachments` are index filters. `dateFrom`/`dateTo` (inclusive, Unix epoch\nmilliseconds) are applied post-hoc to the relevance-ordered, limit-bounded result set\n(a search index cannot range-filter), so a date filter narrows the returned page rather\nthan the underlying scan. `limit` is clamped to 1..50 (default 25). An empty/whitespace `q`\nreturns `422 invalid_query`.\n\n**Rate limit:** 120 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":120,"window":"60s","key":"apikey:{keyId}:search"},"parameters":[{"$ref":"#/components/parameters/SearchTermQuery"},{"$ref":"#/components/parameters/BusinessIdQuery"},{"$ref":"#/components/parameters/StatusQuery"},{"$ref":"#/components/parameters/HasAttachmentsQuery"},{"$ref":"#/components/parameters/DateFromQuery"},{"$ref":"#/components/parameters/DateToQuery"},{"$ref":"#/components/parameters/SearchLimitQuery"}],"responses":{"200":{"description":"Relevance-ranked search results (summary DTOs).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailSearchResponse"},"example":{"results":[{"_id":"m3k9q1w5e7r2t4y6u8i0o9p1","businessId":"j57a2c8e1f4g6h9k3m5p7q0r","subject":"Where is my order?","senderName":"Jane Customer","senderEmail":"customer@example.com","recipientEmail":"support@acme.com","receivedAt":1733140000000,"status":"pending","isRead":false,"hasAttachments":true,"threadId":"t1a2b3c4d5e6f7g8h9i0j1k2","snippet":"Hi, I ordered 3 days ago and have not received a shipping confirmation. Can you help?"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails/{id}":{"get":{"operationId":"getEmail","tags":["Read"],"summary":"Get a single email and its latest draft","description":"Returns the full email - including `body`, `attachments`, and the most recent `draft` (or\n`null`) - for an email inside the key's allowlist.\n\nA malformed, unknown, or out-of-allowlist id returns **`404`** (existence is never leaked).\n\n**Rate limit:** 120 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":120,"window":"60s","key":"apikey:{keyId}:emailGet"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"}],"responses":{"200":{"description":"The email with its latest draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailDetail"},"example":{"_id":"m3k9q1w5e7r2t4y6u8i0o9p1","businessId":"j57a2c8e1f4g6h9k3m5p7q0r","businessName":"Acme Support","senderEmail":"customer@example.com","senderName":"Jane Customer","recipientEmail":"support@acme.com","subject":"Where is my order?","body":"Hi, I ordered 3 days ago and have not received a shipping confirmation. Can you help?","status":"pending","messageId":"<abc123@mail.example.com>","spamScore":0.02,"isStarred":false,"isRead":false,"isComposed":false,"receivedAt":1733140000000,"openCount":0,"clickCount":0,"sentAt":null,"lastEventAt":null,"cc":[],"bcc":[],"attachments":[{"id":"a1b2c3d4e5f6g7h8i9j0k1l2","filename":"receipt.pdf","contentType":"application/pdf","size":48213,"isInline":false,"available":true}],"draft":{"_id":"d1a2b3c4d5e6f7g8h9i0j1k2","emailId":"m3k9q1w5e7r2t4y6u8i0o9p1","body":"Hi Jane, your order shipped yesterday - tracking is attached.","version":2,"feedback":null}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"operationId":"patchEmail","tags":["State"],"summary":"Update email state (read/starred/status)","description":"Set any of `isRead`, `isStarred`, `status`. Allowed status transitions ONLY (mirrors the dashboard): pending->spam, spam->pending, any->trash, trash->pending. Illegal transitions -> `422 invalid_transition`. Spam/not-spam transitions teach the spam filter exactly like the UI.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailPatchRequest"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails/{id}/reply":{"post":{"operationId":"replyToEmail","tags":["Write"],"summary":"Reply to an existing inbound email","description":"Replies to an existing email identified by `{id}` (must be inside the key's allowlist).\n\n**Behavior by mode:**\n- `human_in_the_loop` (always, regardless of `send`): creates/updates the latest draft and\n  puts the email back into the `pending` approval queue. **Nothing is sent.** Returns\n  `201 { \"status\": \"pending_approval\", \"emailId\", \"draftId\" }`.\n- `full_autonomous`:\n  - final-copy mode with `send` omitted or `true`: sends the reply immediately (subject prefixed `Re:`, proper\n    `In-Reply-To`/`References` threading), marks the email `sent`. Returns\n    `200 { \"status\": \"sent\", \"emailId\", \"smtpMessageId\"? }`.\n  - final-copy mode with `send: false`: behaves like HITL (draft only) -> `201 pending_approval`.\n  - generated-copy mode: AutoEmail writes the reply first; omitted/false `send` queues a draft,\n    and only explicit `send: true` sends immediately.\n\nReplying to an already-sent/sending email, or a trashed email, returns `422 invalid_target`.\n\n**Rate limit:** 30 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":30,"window":"60s","key":"apikey:{keyId}:reply"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyRequest"},"examples":{"autonomousSend":{"summary":"Autonomous send (full_autonomous key)","value":{"body":"Your order shipped yesterday - tracking attached."}},"draftForApproval":{"summary":"Draft only (HITL key, or full_autonomous with send=false)","value":{"body":"Draft this for review.","send":false}},"generatedDraft":{"summary":"Let AutoEmail generate the reply and queue it for approval","value":{"mode":"generate","brief":"Apologize for the delay and ask for the order number.","tone":"warm, concise","model":"~google/gemini-flash-latest","send":false}}}}}},"responses":{"200":{"description":"Reply sent (full_autonomous, send not false). Body matches the `ReplySentResult` branch of `ReplyResult`.","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyResult"},"example":{"status":"sent","emailId":"m3k9q1w5e7r2t4y6u8i0o9p1","smtpMessageId":"<generated-id@acme.com>"}}}},"201":{"description":"Draft queued for human approval (HITL, or full_autonomous with send=false). Body matches the `PendingReplyResult` branch of `ReplyResult`.","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyResult"},"example":{"status":"pending_approval","emailId":"m3k9q1w5e7r2t4y6u8i0o9p1","draftId":"d3x4y5z6a7b8c9d0e1f2g3h4"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"$ref":"#/components/responses/AiGenerationFailed"}}}},"/emails/send":{"post":{"operationId":"sendEmail","tags":["Write"],"summary":"Compose and send a new outbound email","description":"Composes a NEW outbound email from one of the key's allowlisted accounts.\n\n**Behavior by mode:**\n- `human_in_the_loop`: inserts a composed email as `pending` (+ draft v1) for dashboard\n  approval. **Nothing is sent.** Returns `201 { \"status\": \"pending_approval\", \"emailId\" }`.\n- `full_autonomous`: final-copy requests send immediately and mark the email `sent`. Generated-copy\n  requests first ask AutoEmail to write the subject/body and send only with explicit `send: true`;\n  omitted/false `send` queues a draft. Sends return\n  `200 { \"status\": \"sent\", \"emailId\", \"smtpMessageId\"? }`.\n\nNote: this endpoint accepts a single `to` recipient only - there are no `cc`/`bcc` fields.\n\n**Rate limit:** 30 requests per key per 60-second window.\n","x-rateLimit":{"perKeyPerMinute":30,"window":"60s","key":"apikey:{keyId}:send"},"parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendRequest"},"examples":{"finalCopy":{"summary":"Caller supplies final subject and body","value":{"businessId":"j57a2c8e1f4g6h9k3m5p7q0r","to":"lead@prospect.com","subject":"Following up on your inquiry","body":"Hi there - thanks for reaching out. Here is the information you requested."}},"generatedDraft":{"summary":"Let AutoEmail generate subject/body and queue for approval","value":{"mode":"generate","businessId":"j57a2c8e1f4g6h9k3m5p7q0r","to":"lead@prospect.com","brief":"Introduce AutoEmail and ask for a 15-minute discovery call.","recipientContext":"Founder of a Shopify agency","tone":"direct, warm, concise","model":"~google/gemini-flash-latest","send":false}}}}}},"responses":{"200":{"description":"Email sent (full_autonomous). Body matches the `SendSentResult` branch of `SendResult`.","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"},"example":{"status":"sent","emailId":"m9z1x2c3v4b5n6m7q8w9e0r1","smtpMessageId":"<generated-id@acme.com>"}}}},"201":{"description":"Composed email queued for human approval (HITL). Body matches the `PendingSendResult` branch of `SendResult`.","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"},"example":{"status":"pending_approval","emailId":"m9z1x2c3v4b5n6m7q8w9e0r1"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"$ref":"#/components/responses/AiGenerationFailed"}}}},"/outreach/batch":{"post":{"operationId":"outreachBatch","tags":["Write"],"summary":"Bulk-send outreach to up to 100 recipients (API-only primitive)","description":"ONE reliable, safe bulk-send primitive for agent-orchestrated outreach. There is no UI for this - it is API-only by design: an LLM composes/orchestrates the campaign and AutoEmail handles deliverability protection, dedupe, quota, and the actual sends.\n\n**Per-recipient personalization**\n- `mode: \"final\"` (default): you supply `subject`/`body`. Simple template placeholders `{{name}}` and `{{email}}` are substituted per recipient (missing name -> empty string).\n- `mode: \"generate\"`: AutoEmail writes a personalized email per recipient from `brief` (+ optional `tone`/`constraints`/`subjectHint`) and the recipient's `name`/`context`, using the configured OpenRouter model, global prompt, knowledge base, and learned lessons.\n\n**Behavior by key mode + `send`**\n- `human_in_the_loop` keys (or any key with `send` omitted/false): every accepted recipient becomes a `pending` composed email + draft v1 in the dashboard approval queue. Nothing is sent. Passing `send: true` with a HITL key is rejected with `403 mode_not_allowed`.\n- `full_autonomous` + `send: true`: accepted recipients are sent over SMTP, **drip-throttled** with a small jittered delay (2-5s) between sends for deliverability. Each send is scheduled asynchronously.\n\n**ASYNC SEMANTICS (important)** - when `send: true`, the per-recipient `status` in the response is `sent_scheduled`: the actual SMTP sends happen asynchronously after the response returns. The final outcome of each send is recorded on the email row's `outreachOutcome` field (`sent` | `skipped_replied` | `send_failed`) and is queryable later via `GET /emails`. A `skipped_replied` outcome means the targeted pre-send reply check found that the recipient had already replied (see below).\n\n**Targeted pre-send reply check (`skipIfReplied`, default true)** - right before each autonomous send, AutoEmail does ONE cheap IMAP search of the mailbox INBOX for a message FROM that recipient within `dedupeWindowHours`. If found, the send is skipped (`outreachOutcome: skipped_replied`). The check fails OPEN (sends anyway) on any IMAP error so a flaky server never blocks outreach.\n\n**Dedupe window (`dedupeWindowHours`, default 72, max 720)** - recipients already contacted by ANY composed/outreach email from this mailbox within the window are skipped up front with `reason: recently_contacted` (protects against double-contact when an LLM re-runs a batch).\n\n**Daily cap (deliverability protection)** - each mailbox has a per-day outreach cap (default 200/day, UTC). If a batch would exceed the cap, recipients up to the cap are processed and the rest are returned as skipped with `reason: daily_cap_reached` (partial success, NOT all-or-nothing).\n\n**Quota** - every recipient (generated or final, drafted or sent) consumes 1 unit of the account's email quota. If the whole batch will not fit, the request fails fast with `402 quota_exceeded` and nothing is staged.\n\n**Rate limit:** 10 requests per key per 60-second window (distinct bucket).\n","x-rateLimit":{"perKeyPerMinute":10,"window":"60s","key":"apikey:{keyId}:outreachBatch"},"parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutreachBatchRequest"},"examples":{"finalTemplate":{"summary":"Final copy with per-recipient template placeholders, queued as drafts","value":{"businessId":"j57a2c8e1f4g6h9k3m5p7q0r","recipients":[{"email":"ada@acme.com","name":"Ada"},{"email":"lin@globex.com","name":"Lin"}],"subject":"Quick question, {{name}}","body":"Hi {{name}} - reaching out at {{email}} about your inbox workflow.","send":false}},"generateAndSend":{"summary":"AI-generated per recipient, autonomous drip send with reply check","value":{"mode":"generate","businessId":"j57a2c8e1f4g6h9k3m5p7q0r","recipients":[{"email":"founder@shopagency.com","name":"Sam","context":"Runs a 12-person Shopify agency"}],"brief":"Introduce AutoEmail and ask for a 15-minute discovery call.","tone":"direct, warm, concise","send":true,"skipIfReplied":true,"dedupeWindowHours":72}}}}}},"responses":{"200":{"description":"Batch processed but nothing was accepted (e.g. every recipient skipped via dedupe/cap). Body is an `OutreachBatchResult`.","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutreachBatchResult"}}}},"201":{"description":"Batch accepted ≥1 recipient (drafts created and/or sends scheduled). Body is an `OutreachBatchResult`. When `send: true`, accepted recipients show `status: sent_scheduled` and the real send outcomes land on each email row's `outreachOutcome` asynchronously.","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutreachBatchResult"},"example":{"batchId":"ob_1717200000000_a1b2c3d4","businessId":"j57a2c8e1f4g6h9k3m5p7q0r","mode":"final","send":true,"results":[{"email":"ada@acme.com","status":"sent_scheduled","emailId":"m9z1x2c3v4b5n6m7q8w9e0r1"},{"email":"lin@globex.com","status":"skipped","reason":"recently_contacted"}],"summary":{"requested":2,"accepted":1,"skipped":1}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/accounts/{businessId}/sync":{"post":{"operationId":"syncAccount","tags":["Sync"],"summary":"Trigger an IMAP sync for one mailbox","description":"Schedules a single IMAP poll for the given allowlisted mailbox and returns immediately (`202 { status: \"started\" }`). New mail becomes visible via `GET /emails` once the poll completes. A per-mailbox 30s cooldown applies: while cooling down you get `429` with a `retryAfterSeconds` field.\n\n**Rate limit:** 6 requests per key per minute.","x-rateLimit":{"perKeyPerMinute":6,"window":"60s"},"parameters":[{"name":"businessId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The businesses document id."}],"responses":{"202":{"description":"Sync started.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["started"]}},"required":["status"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/threads":{"get":{"operationId":"listThreads","tags":["Threads"],"summary":"List conversation threads","description":"Lean thread DTOs scoped to the key's allowlist.\n\n**Rate limit:** 120/min.","x-rateLimit":{"perKeyPerMinute":120,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/BusinessIdQuery"},{"name":"unreadOnly","in":"query","required":false,"schema":{"type":"boolean"},"description":"When true, return only threads with unread mail."},{"$ref":"#/components/parameters/PageQuery"},{"name":"pageSize","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"description":"Page size (max 50)."}],"responses":{"200":{"description":"Paginated thread list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThreadListResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/threads/{id}":{"get":{"operationId":"getThread","tags":["Threads"],"summary":"Get a thread + message summaries","description":"Returns the thread plus chronological message SUMMARIES (300-char snippets, not full bodies - fetch a full body via `GET /emails/{id}`).\n\n**Rate limit:** 120/min.","x-rateLimit":{"perKeyPerMinute":120,"window":"60s"},"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The threads document id."}],"responses":{"200":{"description":"Thread + message summaries.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThreadDetail"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails/{id}/drafts":{"get":{"operationId":"listDrafts","tags":["Drafts"],"summary":"List draft versions of an email","description":"Every draft version (oldest first).\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"}],"responses":{"200":{"description":"Draft versions.","content":{"application/json":{"schema":{"type":"object","properties":{"drafts":{"type":"array","items":{"$ref":"#/components/schemas/DraftVersion"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails/{id}/approve":{"post":{"operationId":"approveEmail","tags":["Drafts"],"summary":"Approve + send the latest draft","description":"Sends the latest draft of a pending email. **full_autonomous keys only** - HITL keys get `403 mode_not_allowed`. Optional `draftBody` records a new draft version then sends. Consumes 1 quota unit (fail-fast `402 quota_exceeded`).\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"draftBody":{"type":"string","maxLength":50000,"description":"Optional override body recorded as a new draft version before sending."}}}}}},"responses":{"200":{"description":"Sent.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["sent"]},"emailId":{"$ref":"#/components/schemas/ConvexId"},"smtpMessageId":{"type":"string"}},"required":["status","emailId"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails/{id}/decline":{"post":{"operationId":"declineEmail","tags":["Drafts"],"summary":"Decline a pending email","description":"Declines a pending email. Optional `feedback` is summarized into a learning that improves future drafts (the same extraction the dashboard runs). Allowed in BOTH key modes.\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"feedback":{"type":"string","maxLength":4000,"description":"Why the draft was wrong - turned into a learning when present."}}}}}},"responses":{"200":{"description":"Declined.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["declined"]},"emailId":{"$ref":"#/components/schemas/ConvexId"},"lessonCreated":{"oneOf":[{"$ref":"#/components/schemas/ConvexId"},{"type":"null"}]}},"required":["status","emailId"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/emails/{id}/attachments":{"get":{"operationId":"listAttachments","tags":["Attachments"],"summary":"List attachment metadata for an email","description":"Metadata only (no bytes). `available` is false for metadata-only rows.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/EmailIdPath"}],"responses":{"200":{"description":"Attachment metadata.","content":{"application/json":{"schema":{"type":"object","properties":{"attachments":{"type":"array","items":{"$ref":"#/components/schemas/AttachmentMeta"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/attachments/{id}/download-url":{"get":{"operationId":"getAttachmentDownloadUrl","tags":["Attachments"],"summary":"Get a short-lived attachment download URL","description":"Returns a short-lived storage URL. `404` when the attachment is metadata-only or unknown.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The attachments document id."}],"responses":{"200":{"description":"Download URL.","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"expiresNote":{"type":"string"}},"required":["url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/contacts":{"get":{"operationId":"listContacts","tags":["Contacts"],"summary":"List/search contacts","description":"Contacts scoped to the allowlist.\n\n**Rate limit:** 120/min.","x-rateLimit":{"perKeyPerMinute":120,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/BusinessIdQuery"},{"$ref":"#/components/parameters/SearchQuery"},{"$ref":"#/components/parameters/PageQuery"},{"name":"pageSize","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Paginated contacts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactListResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"createContact","tags":["Contacts"],"summary":"Create or upsert a contact","description":"Upsert by (businessId, email): an existing contact with the same email is UPDATED. `201` when created, `200` when updated.\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"businessId":{"$ref":"#/components/schemas/ConvexId"},"email":{"type":"string","format":"email","maxLength":254},"firstName":{"type":"string","maxLength":200},"lastName":{"type":"string","maxLength":200},"company":{"type":"string","maxLength":200},"phone":{"type":"string","maxLength":50},"tags":{"type":"array","items":{"type":"string","maxLength":80},"maxItems":50},"notes":{"type":"string","maxLength":4000}},"required":["businessId","email"]}}}},"responses":{"200":{"description":"Updated (upsert).","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"created":{"type":"boolean"}},"required":["id","created"]}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"created":{"type":"boolean"}},"required":["id","created"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/contacts/{id}":{"patch":{"operationId":"updateContact","tags":["Contacts"],"summary":"Update a contact","description":"Patch any provided fields. Changing `email` to one that already exists -> `422 duplicate_email`.\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The contacts document id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email","maxLength":254},"firstName":{"type":"string","maxLength":200},"lastName":{"type":"string","maxLength":200},"company":{"type":"string","maxLength":200},"phone":{"type":"string","maxLength":50},"tags":{"type":"array","items":{"type":"string","maxLength":80},"maxItems":50},"notes":{"type":"string","maxLength":4000}}}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"deleteContact","tags":["Contacts"],"summary":"Delete a contact","description":"Delete a contact.\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The contacts document id."}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/calendar-events":{"get":{"operationId":"listCalendarEvents","tags":["Calendar"],"summary":"List calendar events (read-only)","description":"Events scoped to the allowlist, optional `from`/`to` ms range.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"$ref":"#/components/parameters/BusinessIdQuery"},{"name":"from","in":"query","required":false,"schema":{"type":"integer","minimum":0},"description":"Range start (Unix epoch ms), matched on startAt."},{"name":"to","in":"query","required":false,"schema":{"type":"integer","minimum":0},"description":"Range end (Unix epoch ms), matched on startAt."},{"$ref":"#/components/parameters/PageQuery"},{"name":"pageSize","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Paginated calendar events.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarListResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/accounts/{businessId}/spam-settings":{"get":{"operationId":"getSpamSettings","tags":["Spam"],"summary":"Get spam settings","description":"Returns `{ spamThreshold, spamCustomRules }`.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"name":"businessId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The businesses document id."}],"responses":{"200":{"description":"Spam settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpamSettings"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"operationId":"updateSpamSettings","tags":["Spam"],"summary":"Update spam settings","description":"Update threshold (0..100) and/or custom rules. Changing the threshold reclassifies pending/spam mail (same as the dashboard).\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"name":"businessId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The businesses document id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpamSettingsUpdate"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/accounts/{businessId}/lessons":{"get":{"operationId":"listLessons","tags":["Lessons"],"summary":"List active learnings + spam lessons","description":"Active reply learnings + spam lessons for the account.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"parameters":[{"name":"businessId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The businesses document id."}],"responses":{"200":{"description":"Lessons.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LessonsResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"addLesson","tags":["Lessons"],"summary":"Add a manual learning","description":"Add a manual reply learning (<=2000 chars). Enforces the 50-lesson cap (oldest evicted).\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"name":"businessId","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The businesses document id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string","minLength":1,"maxLength":2000},"title":{"type":"string","maxLength":200}},"required":["content"]}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"}},"required":["id"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/lessons/{id}":{"delete":{"operationId":"deleteLesson","tags":["Lessons"],"summary":"Delete a learning","description":"Delete a reply learning.\n\n**Rate limit:** 30/min.","x-rateLimit":{"perKeyPerMinute":30,"window":"60s"},"parameters":[{"name":"id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConvexId"},"description":"The lessons document id."}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/usage":{"get":{"operationId":"getUsage","tags":["Usage"],"summary":"Get current-period quota usage","description":"Returns the key owner's current-period email quota so an agent can self-throttle BEFORE hitting `402`.\n\n**Rate limit:** 60/min.","x-rateLimit":{"perKeyPerMinute":60,"window":"60s"},"responses":{"200":{"description":"Usage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Usage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Provide the API key as a Bearer token:\n`Authorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.\nAuth fails closed (401) on a missing/non-Bearer header, an empty token, an unknown key, or a\nrevoked/disabled key.\n"}},"parameters":{"BusinessIdQuery":{"name":"businessId","in":"query","required":false,"description":"Restrict the list to one account. Must be a valid Convex id (else `422 invalid_business_id`) AND in this key's allowlist (else `403 business_not_allowed`). When omitted, the list spans all of the key's allowlisted accounts.","schema":{"$ref":"#/components/schemas/ConvexId"}},"StatusQuery":{"name":"status","in":"query","required":false,"description":"Filter by email status. Accepts the `EmailStatusFilter` enum ONLY (`pending`, `approved`, `sent`, `declined`, `spam`) - a strict subset of the statuses a record may carry. The internal `sending`/`trash` states are NOT valid filter values; any other value returns `422 invalid_status`. When omitted, trashed emails are excluded automatically.","schema":{"$ref":"#/components/schemas/EmailStatusFilter"}},"SearchQuery":{"name":"q","in":"query","required":false,"description":"Case-insensitive substring search over subject, sender name, and recipient email. Maximum 200 characters (else `422 invalid_query`).","schema":{"type":"string","maxLength":200}},"OutreachBatchIdQuery":{"name":"outreachBatchId","in":"query","required":false,"description":"Restrict the list to the rows of ONE outreach batch - the `batchId` returned by `POST /outreach/batch`. Each row carries its `outreachOutcome` (`sent` | `skipped_replied` | `send_failed`, or `null` while still in flight), so this is how you poll a `send: true` batch's per-recipient outcomes after the bulk-send response returns. Only rows in this key's allowlisted accounts are returned. Format `ob_<timestamp>_<alnum>`; a malformed value returns `422 invalid_outreach_batch_id`.","schema":{"type":"string","pattern":"^ob_[0-9]+_[a-z0-9]{1,16}$"}},"PageQuery":{"name":"page","in":"query","required":false,"description":"1-based page number. Values below 1 are coerced to 1.","schema":{"type":"integer","minimum":1,"default":1}},"PageSizeQuery":{"name":"pageSize","in":"query","required":false,"description":"Page size. Clamped to the range 1..100.","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},"SearchTermQuery":{"name":"q","in":"query","required":true,"description":"The full-text search term. Tokenized + relevance-ranked over subject, sender name, sender email, recipient email, and the plain-text body. Must be 1..200 characters after trimming (empty/whitespace or over-long returns `422 invalid_query`).","schema":{"type":"string","minLength":1,"maxLength":200},"example":"shipping confirmation"},"HasAttachmentsQuery":{"name":"hasAttachments","in":"query","required":false,"description":"Filter to emails that do (`true`) or do not (`false`) have at least one non-inline attachment. Any other value returns `422 invalid_has_attachments`.","schema":{"type":"boolean"}},"DateFromQuery":{"name":"dateFrom","in":"query","required":false,"description":"Inclusive lower bound on `receivedAt` (Unix epoch milliseconds, non-negative integer). Applied post-hoc to the relevance-ordered, limit-bounded results. A non-integer/negative value returns `422 invalid_date`.","schema":{"type":"integer","minimum":0},"example":1733000000000},"DateToQuery":{"name":"dateTo","in":"query","required":false,"description":"Inclusive upper bound on `receivedAt` (Unix epoch milliseconds, non-negative integer). Applied post-hoc to the relevance-ordered, limit-bounded results. A non-integer/negative value returns `422 invalid_date`.","schema":{"type":"integer","minimum":0},"example":1733200000000},"SearchLimitQuery":{"name":"limit","in":"query","required":false,"description":"Maximum number of results to return. Clamped to the range 1..50.","schema":{"type":"integer","minimum":1,"maximum":50,"default":25}},"EmailIdPath":{"name":"id","in":"path","required":true,"description":"Convex email id (lowercase alphanumeric). A malformed/unknown/out-of-allowlist id yields `404 not_found`.","schema":{"$ref":"#/components/schemas/ConvexId"}},"IdempotencyKeyHeader":{"name":"Idempotency-Key","in":"header","required":false,"description":"**Optional, but STRONGLY RECOMMENDED for automated / full-autonomous clients.** Safe-retry\nkey (Stripe-style): supply a unique value to make a write processed AT MOST ONCE even if the\nnetwork drops or you retry. Automated senders should ALWAYS set this header - generate a\nFRESH unique key (a UUID v4 is ideal) for each DISTINCT logical email, and reuse that SAME\nkey on every retry of that email so a timeout or `5xx` replays the original result instead of\nsending twice. Never reuse a key for a different payload (you will get `422\nidempotency_key_reused`).\n\nBehavior:\n- **Replay:** a retry with the SAME key AND the SAME request body returns the ORIGINAL stored\n  response verbatim (same status code + body), with the response header\n  `Idempotency-Replayed: true`. Nothing is re-sent and no duplicate draft/email is created.\n- **In progress (`409 idempotency_in_progress`):** the first request with this key is still\n  being processed. Wait and retry.\n- **Reuse conflict (`422 idempotency_key_reused`):** the key was already used with a\n  DIFFERENT request body. Use a fresh key for a different payload.\n- **Malformed (`422 invalid_idempotency_key`):** the header is present but not 1..255\n  printable-ASCII characters.\n\nKeys are scoped per API key and expire after **24 hours**; after expiry the same key value is\ntreated as new. Omitting the header preserves the legacy (non-idempotent) behavior exactly.\nTransient `5xx` responses are NOT cached - the key may be retried after a server error.\n","schema":{"type":"string","minLength":1,"maxLength":255,"pattern":"^[\\x20-\\x7e]{1,255}$"},"example":"7f3c1e2a-0b4d-4c9e-8a21-9d6f0b2e5c14"}},"headers":{"IdempotencyReplayed":{"description":"Present and set to `true` ONLY when this response is a verbatim REPLAY of an earlier request carrying the same `Idempotency-Key` (and identical body). Absent on the first (live) request.","schema":{"type":"string","enum":["true"]},"example":"true"}},"responses":{"Unauthorized":{"description":"Authentication failed. `error` is `missing_bearer_token` (missing/non-Bearer/empty header) or `invalid_or_revoked_key` (unknown or disabled key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missingBearer":{"value":{"error":"missing_bearer_token","message":"Provide an Authorization: Bearer <key> header."}},"invalidKey":{"value":{"error":"invalid_or_revoked_key","message":"The API key is invalid or revoked."}}}}}},"Forbidden":{"description":"The key may not access the requested account (businessId not in allowlist).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"business_not_allowed","message":"This key may not access that account."}}}},"NotFound":{"description":"Resource not found - OR outside the key's allowlist (existence is never leaked). Also returned as a bare `404` for any path that is not one of the six documented routes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"not_found","message":"Email not found."}}}},"ValidationError":{"description":"Request validation failed. `error` is one of `invalid_json`, `invalid_mode`, `invalid_business_id`, `invalid_recipient`, `invalid_subject`, `invalid_body`, `invalid_brief`, `invalid_model`, `invalid_status`, `invalid_query`, `invalid_has_attachments`, `invalid_date`, `invalid_send`, `invalid_target`, `invalid_idempotency_key` (present but malformed `Idempotency-Key`), or `idempotency_key_reused` (same `Idempotency-Key` used with a different request body).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalidRecipient":{"value":{"error":"invalid_recipient","message":"to must be a valid email address (max 254 chars)."}},"invalidTarget":{"value":{"error":"invalid_target","message":"Cannot reply to a trashed email."}},"invalidIdempotencyKey":{"value":{"error":"invalid_idempotency_key","message":"Idempotency-Key must be 1..255 printable ASCII characters."}},"idempotencyKeyReused":{"value":{"error":"idempotency_key_reused","message":"This Idempotency-Key was already used with a different request payload."}}}}}},"Conflict":{"description":"A request with this `Idempotency-Key` is still being processed. Wait briefly and retry the same request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"idempotency_in_progress","message":"A request with this Idempotency-Key is still being processed."}}}},"RateLimited":{"description":"Per-key, per-route rate limit exceeded (60-second sliding window).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"rate_limited","message":"Rate limit exceeded. Try again shortly."}}}},"PaymentRequired":{"description":"Insufficient email quota to process the whole batch (fail-fast, nothing staged). The message reports the remaining quota and the number required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"quota_exceeded","message":"Insufficient email quota for this batch: 5 remaining, 40 required."}}}},"InternalError":{"description":"Unexpected server error, or a send failure. The message is always generic - raw SMTP/internal errors are never forwarded. `error` is `internal_error` or `send_failed`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"internalError":{"value":{"error":"internal_error","message":"Something went wrong."}},"sendFailed":{"value":{"error":"send_failed","message":"Failed to send the email."}}}}}},"AiGenerationFailed":{"description":"OpenRouter/model copy generation failed before a draft/send was created. The message is generic and does not expose provider internals.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"ai_generation_failed","message":"Failed to generate email copy."}}}}},"schemas":{"ConvexId":{"type":"string","description":"A Convex document id - lowercase alphanumeric characters only.","pattern":"^[a-z0-9]+$","example":"m3k9q1w5e7r2t4y6u8i0o9p1"},"EmailStatusFilter":{"type":"string","description":"**Accepted values for the `status` QUERY filter on `GET /emails`.** This is a STRICT subset\nof the statuses an email may actually carry (see `EmailStatusValue`): the transient\n`sending` state and the `trash` state are intentionally NOT selectable as filters.\n- `pending` - awaiting approval (or freshly received and not yet handled).\n- `approved` - approved (transitional).\n- `sent` - delivered over SMTP.\n- `declined` - rejected by the human reviewer.\n- `spam` - classified as spam.\n\nAny other value returns `422 invalid_status`. When the filter is omitted, trashed emails are\nexcluded from the result automatically.\n","enum":["pending","approved","sent","declined","spam"]},"EmailStatusValue":{"type":"string","description":"**The `status` value that may appear in a RESPONSE body** (`EmailListItem.status` /\n`EmailDetail.status`). This is the FULL stored status set and is a SUPERSET of the\n`EmailStatusFilter` query enum - it additionally includes the internal `sending` and `trash`\nstates, which exist on records but cannot be used as a `GET /emails` filter value.\n- `pending` - awaiting approval (or freshly received and not yet handled).\n- `sending` - an outbound email is mid-send over SMTP (transient).\n- `approved` - approved (transitional).\n- `sent` - delivered over SMTP.\n- `declined` - rejected by the human reviewer.\n- `spam` - classified as spam.\n- `trash` - moved to trash (excluded from `GET /emails` results; not usable as a filter).\n","enum":["pending","sending","approved","sent","declined","spam","trash"]},"Mode":{"type":"string","description":"The key's operating mode.\n- `human_in_the_loop` - writes create a `pending` draft for human approval (never sends).\n- `full_autonomous` - writes send immediately over SMTP.\n","enum":["human_in_the_loop","full_autonomous"]},"Error":{"type":"object","description":"The uniform error envelope returned by every error response.","additionalProperties":false,"required":["error","message"],"properties":{"error":{"type":"string","description":"Stable machine-readable error code.","examples":["not_found","invalid_recipient","rate_limited","internal_error"]},"message":{"type":"string","description":"Human-readable explanation. Never contains internal/SMTP details or secrets."}}},"Account":{"type":"object","description":"A non-secret account (business) DTO. Never includes SMTP/IMAP credentials.","additionalProperties":false,"required":["businessId","domain","email","name"],"properties":{"businessId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The business id to use for filtering and as `businessId` in POST /emails/send."},"domain":{"type":"string","description":"The account's domain.","example":"acme.com"},"email":{"type":"string","format":"email","description":"The account's from-address.","example":"support@acme.com"},"name":{"type":"string","description":"The account's display name (also used as the sender name).","example":"Acme Support"}}},"KeyInfo":{"type":"object","description":"Identity and capabilities of the authenticated key (GET /me).","additionalProperties":false,"required":["keyId","name","mode","allowedAccounts"],"properties":{"keyId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The id of the API key (NOT the secret; safe to log)."},"name":{"type":"string","description":"The human-assigned label for the key.","example":"Support Agent"},"mode":{"$ref":"#/components/schemas/Mode"},"allowedAccounts":{"type":"array","description":"Accounts this key may read and send from.","items":{"$ref":"#/components/schemas/Account"}}}},"AccountListResponse":{"type":"object","description":"Response of GET /accounts.","additionalProperties":false,"required":["accounts"],"properties":{"accounts":{"type":"array","items":{"$ref":"#/components/schemas/Account"}}}},"Attachment":{"type":"object","description":"Attachment metadata (no binary content is exposed).","additionalProperties":false,"required":["filename","contentType","size"],"properties":{"filename":{"type":"string","example":"receipt.pdf"},"contentType":{"type":"string","description":"MIME type.","example":"application/pdf"},"size":{"type":"integer","description":"Size in bytes.","example":48213}}},"Draft":{"type":["object","null"],"description":"The latest draft for an email, or null if none exists.","additionalProperties":false,"required":["_id","emailId","body","version","feedback"],"properties":{"_id":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The draft id."},"emailId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The id of the email this draft belongs to."},"body":{"type":"string","description":"The draft reply/compose body (plain text)."},"version":{"type":"integer","description":"Monotonic draft version (highest = latest).","example":2},"feedback":{"type":["string","null"],"description":"Optional reviewer feedback recorded against the draft."}}},"EmailListItem":{"type":"object","description":"A single email in the GET /emails list. This is the agent-API list DTO; it intentionally does\nNOT include `body`, `attachments`, `cc`, `bcc`, `toRecipients`, or `ccRecipients`. Fetch\nGET /emails/{id} for the full body + attachments.\n","additionalProperties":false,"required":["_id","businessId","businessName","senderEmail","senderName","subject","status","receivedAt","isStarred","isRead","isComposed","hasDraft","openCount","clickCount"],"properties":{"_id":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The email id."},"businessId":{"$ref":"#/components/schemas/ConvexId"},"businessName":{"type":"string","example":"Acme Support"},"senderEmail":{"type":"string","format":"email","example":"customer@example.com"},"senderName":{"type":"string","example":"Jane Customer"},"recipientEmail":{"type":["string","null"],"description":"The to-address. Set on composed/outbound emails and on inbound emails where it was captured; may be absent (null) on older inbound rows.","example":"support@acme.com"},"subject":{"type":"string","example":"Where is my order?"},"status":{"allOf":[{"$ref":"#/components/schemas/EmailStatusValue"}],"description":"The raw stored status. This is the FULL `EmailStatusValue` set and may include the internal `sending`/`trash` values - a SUPERSET of the `EmailStatusFilter` query enum.","example":"pending"},"receivedAt":{"type":"integer","description":"Receipt time (Unix epoch milliseconds). For composed emails, the creation time.","example":1733140000000},"spamScore":{"type":["number","null"],"description":"Spam score in 0..1, or null if unscored.","example":0.02},"isStarred":{"type":"boolean"},"isRead":{"type":"boolean"},"isComposed":{"type":"boolean","description":"True for outbound emails composed/sent by the app; false for received emails."},"hasDraft":{"type":"boolean","description":"Whether a draft exists for this email."},"openCount":{"type":"integer","description":"Number of tracked opens."},"clickCount":{"type":"integer","description":"Number of tracked link clicks."},"sentAt":{"type":["integer","null"],"description":"Send time (Unix epoch milliseconds), or null if not sent."},"lastEventAt":{"type":["integer","null"],"description":"Time of the last open/click event (Unix epoch milliseconds), or null."},"outreachBatchId":{"type":["string","null"],"description":"The outreach batch this row belongs to (from POST /outreach/batch), or null on non-outreach mail. Filter the list to one batch with the `outreachBatchId` query param."},"outreachOutcome":{"type":["string","null"],"enum":["sent","skipped_replied","send_failed",null],"description":"Terminal outcome of an async (drip-scheduled) outreach send: `sent`, `skipped_replied` (a reply arrived before the send), or `send_failed`. Null on non-outreach mail and while a `send: true` recipient is still in flight."}}},"EmailListResponse":{"type":"object","description":"A paginated page of emails (GET /emails).","additionalProperties":false,"required":["items","page","pageSize","total","totalPages"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/EmailListItem"}},"page":{"type":"integer","description":"The current 1-based page.","example":1},"pageSize":{"type":"integer","description":"The effective page size (after clamping to 1..100).","example":20},"total":{"type":"integer","description":"Total number of matching emails across all pages.","example":1},"totalPages":{"type":"integer","description":"Total number of pages for the current filter.","example":1}}},"EmailSearchItem":{"type":"object","description":"A single full-text search result (GET /search). A lean summary DTO: it deliberately does\nNOT include the full `body`, any HTML, attachments metadata, `cc`/`bcc`, or draft. Use\nGET /emails/{id} for the full email. `snippet` is the leading ~140 chars of the plain-text\nbody.\n","additionalProperties":false,"required":["_id","businessId","subject","senderName","senderEmail","recipientEmail","receivedAt","status","isRead","hasAttachments","threadId","snippet"],"properties":{"_id":{"$ref":"#/components/schemas/ConvexId"},"businessId":{"$ref":"#/components/schemas/ConvexId"},"subject":{"type":"string","example":"Where is my order?"},"senderName":{"type":"string","example":"Jane Customer"},"senderEmail":{"type":"string","format":"email","example":"customer@example.com"},"recipientEmail":{"type":["string","null"],"description":"The to-address; may be null on older inbound rows.","example":"support@acme.com"},"receivedAt":{"type":"integer","description":"Receipt time (Unix epoch milliseconds).","example":1733140000000},"status":{"allOf":[{"$ref":"#/components/schemas/EmailStatusValue"}],"description":"The raw stored status (full `EmailStatusValue` set).","example":"pending"},"isRead":{"type":"boolean"},"hasAttachments":{"type":"boolean","description":"True when the email has at least one non-inline attachment."},"threadId":{"type":["string","null"],"description":"The conversation thread id (Convex id), or null if not yet threaded. Group results client-side by this value.","example":"t1a2b3c4d5e6f7g8h9i0j1k2"},"snippet":{"type":"string","description":"Leading ~140 characters of the plain-text body. Never the full body or HTML.","example":"Hi, I ordered 3 days ago and have not received a shipping confirmation."}}},"EmailSearchResponse":{"type":"object","description":"Response of GET /search - a relevance-ordered list of search result summaries.","additionalProperties":false,"required":["results"],"properties":{"results":{"type":"array","description":"Matching emails in relevance order (best match first), capped at the effective `limit`.","items":{"$ref":"#/components/schemas/EmailSearchItem"}}}},"EmailDetail":{"type":"object","description":"The full email plus its latest draft (GET /emails/{id}).","additionalProperties":false,"required":["_id","businessId","businessName","senderEmail","senderName","subject","body","status","messageId","isStarred","isRead","isComposed","receivedAt","openCount","clickCount","cc","bcc","attachments","draft"],"properties":{"_id":{"$ref":"#/components/schemas/ConvexId"},"businessId":{"$ref":"#/components/schemas/ConvexId"},"businessName":{"type":"string","example":"Acme Support"},"senderEmail":{"type":"string","format":"email","example":"customer@example.com"},"senderName":{"type":"string","example":"Jane Customer"},"recipientEmail":{"type":["string","null"],"description":"The to-address; may be null on older inbound rows.","example":"support@acme.com"},"subject":{"type":"string","example":"Where is my order?"},"body":{"type":"string","description":"The full plain-text email body."},"status":{"allOf":[{"$ref":"#/components/schemas/EmailStatusValue"}],"description":"The raw stored status. This is the FULL `EmailStatusValue` set and may include the internal `sending`/`trash` values - a SUPERSET of the `EmailStatusFilter` query enum.","example":"pending"},"messageId":{"type":"string","description":"The RFC 5322 Message-ID of the email.","example":"<abc123@mail.example.com>"},"spamScore":{"type":["number","null"],"example":0.02},"isStarred":{"type":"boolean"},"isRead":{"type":"boolean"},"isComposed":{"type":"boolean"},"receivedAt":{"type":"integer","description":"Unix epoch milliseconds.","example":1733140000000},"openCount":{"type":"integer"},"clickCount":{"type":"integer"},"sentAt":{"type":["integer","null"],"description":"Send time (Unix epoch milliseconds), or null."},"lastEventAt":{"type":["integer","null"],"description":"Last open/click event time (Unix epoch milliseconds), or null."},"cc":{"type":"array","description":"Carbon-copy recipients (lowercased). Empty unless the email is composed mail or an inbound reply prepared via Reply All.","items":{"type":"string","format":"email"}},"bcc":{"type":"array","description":"Blind-carbon-copy recipients (lowercased). Empty unless the email is composed mail with bcc.","items":{"type":"string","format":"email"}},"outreachBatchId":{"type":["string","null"],"description":"The outreach batch this row belongs to (from POST /outreach/batch), or null on non-outreach mail."},"outreachOutcome":{"type":["string","null"],"enum":["sent","skipped_replied","send_failed",null],"description":"Terminal outcome of an async (drip-scheduled) outreach send: `sent`, `skipped_replied`, or `send_failed`. Null on non-outreach mail and while a `send: true` recipient is still in flight."},"attachments":{"type":"array","description":"Attachment metadata (possibly empty), sourced from the attachments table (the source of truth for inbound AND outbound attachments). No binary content is served; fetch a download URL via GET /attachments/{id}/download-url when `available` is true. Legacy inbound rows predating the attachments table return entries with `id: null` and `available: false`.","items":{"$ref":"#/components/schemas/EmailDetailAttachment"}},"draft":{"$ref":"#/components/schemas/Draft"}}},"ReplyRequest":{"description":"Body for POST /emails/{id}/reply. Omit `mode` or use `final` for caller-written copy; use `generate` for AutoEmail-written copy.","oneOf":[{"$ref":"#/components/schemas/ReplyFinalRequest"},{"$ref":"#/components/schemas/ReplyGenerateRequest"}]},"ReplyFinalRequest":{"type":"object","additionalProperties":false,"required":["body"],"properties":{"mode":{"type":"string","const":"final","description":"Optional. May be omitted for backward compatibility."},"body":{"type":"string","description":"The final reply text (plain). Trimmed length must be 1..50000 characters. Do not include the mailbox signature: if the source mailbox has a signature configured it is appended automatically on send.","minLength":1,"maxLength":50000,"example":"Your order shipped yesterday - tracking attached."},"send":{"type":"boolean","description":"Optional. For `full_autonomous` final-copy replies, `false` forces draft-only behavior instead of sending. Ignored by `human_in_the_loop` keys."}}},"ReplyGenerateRequest":{"type":"object","additionalProperties":false,"required":["mode"],"properties":{"mode":{"type":"string","const":"generate"},"brief":{"type":"string","description":"Optional instructions for this reply. If omitted, AutoEmail replies from email + business context.","minLength":1,"maxLength":4000},"tone":{"type":"string","minLength":1,"maxLength":500},"context":{"type":"string","minLength":1,"maxLength":8000},"constraints":{"type":"string","minLength":1,"maxLength":4000},"model":{"type":"string","description":"Optional OpenRouter text model id. Defaults to the app's configured model.","example":"~google/gemini-flash-latest"},"send":{"type":"boolean","description":"In generated mode, omitted/false queues a draft. Only a `full_autonomous` key with `send: true` sends immediately."}}},"SendRequest":{"description":"Body for POST /emails/send. Single recipient only (no cc/bcc).","oneOf":[{"$ref":"#/components/schemas/SendFinalRequest"},{"$ref":"#/components/schemas/SendGenerateRequest"}]},"SendFinalRequest":{"type":"object","additionalProperties":false,"required":["businessId","to","subject","body"],"properties":{"mode":{"type":"string","const":"final","description":"Optional. May be omitted for backward compatibility."},"businessId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The account to send FROM. Must be a valid id (`422 invalid_business_id`) and in the key's allowlist (`403 business_not_allowed`)."},"to":{"type":"string","format":"email","description":"Recipient email address. Must be valid and at most 254 characters.","maxLength":254,"example":"lead@prospect.com"},"subject":{"type":"string","description":"Subject line. Trimmed length must be 1..255 characters.","minLength":1,"maxLength":255,"example":"Following up on your inquiry"},"body":{"type":"string","description":"Email body (plain). Trimmed length must be 1..50000 characters. Do not include the mailbox signature: if the source mailbox has a signature configured it is appended automatically on send.","minLength":1,"maxLength":50000,"example":"Hi there - thanks for reaching out."}}},"SendGenerateRequest":{"type":"object","additionalProperties":false,"required":["mode","businessId","to","brief"],"properties":{"mode":{"type":"string","const":"generate"},"businessId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The account to send FROM. Must be in the key's allowlist."},"to":{"type":"string","format":"email","maxLength":254,"example":"lead@prospect.com"},"brief":{"type":"string","description":"What the outbound email should accomplish.","minLength":1,"maxLength":4000},"recipientName":{"type":"string","minLength":1,"maxLength":200},"recipientContext":{"type":"string","minLength":1,"maxLength":2000},"subjectHint":{"type":"string","minLength":1,"maxLength":255},"tone":{"type":"string","minLength":1,"maxLength":500},"context":{"type":"string","minLength":1,"maxLength":8000},"constraints":{"type":"string","minLength":1,"maxLength":4000},"model":{"type":"string","description":"Optional OpenRouter text model id. Defaults to the app's configured model.","example":"~google/gemini-flash-latest"},"send":{"type":"boolean","description":"In generated mode, omitted/false queues a draft. Only a `full_autonomous` key with `send: true` sends immediately."}}},"GeneratedReplyCopy":{"type":"object","additionalProperties":false,"required":["body","model"],"properties":{"body":{"type":"string","description":"The AI-generated reply body that was queued or sent.","minLength":1,"maxLength":50000},"model":{"type":"string","description":"OpenRouter text model used for generation.","example":"~google/gemini-flash-latest"}}},"GeneratedComposedCopy":{"type":"object","additionalProperties":false,"required":["subject","body","model"],"properties":{"subject":{"type":"string","minLength":1,"maxLength":255},"body":{"type":"string","minLength":1,"maxLength":50000},"model":{"type":"string","description":"OpenRouter text model used for generation.","example":"~google/gemini-flash-latest"}}},"ReplySentResult":{"type":"object","description":"Result of an autonomous reply that was sent.","additionalProperties":false,"required":["status","emailId"],"properties":{"status":{"type":"string","const":"sent"},"emailId":{"$ref":"#/components/schemas/ConvexId"},"smtpMessageId":{"type":"string","description":"The SMTP Message-ID of the sent reply, if the transport returned one.","example":"<generated-id@acme.com>"},"generated":{"$ref":"#/components/schemas/GeneratedReplyCopy"}}},"PendingReplyResult":{"type":"object","description":"Result of a reply queued as a draft for human approval.","additionalProperties":false,"required":["status","emailId","draftId"],"properties":{"status":{"type":"string","const":"pending_approval"},"emailId":{"$ref":"#/components/schemas/ConvexId"},"draftId":{"$ref":"#/components/schemas/ConvexId"},"generated":{"$ref":"#/components/schemas/GeneratedReplyCopy"}}},"SendSentResult":{"type":"object","description":"Result of an autonomous compose-and-send.","additionalProperties":false,"required":["status","emailId"],"properties":{"status":{"type":"string","const":"sent"},"emailId":{"$ref":"#/components/schemas/ConvexId"},"smtpMessageId":{"type":"string","description":"The SMTP Message-ID of the sent email, if the transport returned one.","example":"<generated-id@acme.com>"},"generated":{"$ref":"#/components/schemas/GeneratedComposedCopy"}}},"PendingSendResult":{"type":"object","description":"Result of a composed email queued for human approval.","additionalProperties":false,"required":["status","emailId"],"properties":{"status":{"type":"string","const":"pending_approval"},"emailId":{"$ref":"#/components/schemas/ConvexId"},"generated":{"$ref":"#/components/schemas/GeneratedComposedCopy"}}},"ReplyResult":{"description":"Union of the two possible POST /emails/{id}/reply result bodies.","oneOf":[{"$ref":"#/components/schemas/ReplySentResult"},{"$ref":"#/components/schemas/PendingReplyResult"}]},"SendResult":{"description":"Union of the two possible POST /emails/send result bodies.","oneOf":[{"$ref":"#/components/schemas/SendSentResult"},{"$ref":"#/components/schemas/PendingSendResult"}]},"OutreachRecipient":{"type":"object","additionalProperties":false,"required":["email"],"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"Recipient email address. Must be valid (≤254 chars).","example":"ada@acme.com"},"name":{"type":"string","maxLength":200,"description":"Optional display name. Substituted for `{{name}}` in final-mode templates; passed to the AI in generate mode.","example":"Ada"},"context":{"type":"string","maxLength":2000,"description":"Optional free-text personalization context (generate mode), e.g. how you know them or what they do.","example":"Runs a 12-person Shopify agency"}}},"OutreachBatchRequest":{"description":"Body for POST /outreach/batch. `mode: \"final\"` (default) requires `subject`/`body` (template placeholders `{{name}}`/`{{email}}` allowed); `mode: \"generate\"` requires `brief`.","oneOf":[{"$ref":"#/components/schemas/OutreachFinalRequest"},{"$ref":"#/components/schemas/OutreachGenerateRequest"}]},"OutreachCommonProps":{"type":"object","properties":{"businessId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The account to send FROM. Must be valid (`422 invalid_business_id`) and in the key's allowlist (`403 business_not_allowed`)."},"recipients":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/OutreachRecipient"},"description":"1..100 recipients."},"send":{"type":"boolean","default":false,"description":"Only honored for `full_autonomous` keys. `true` on a `human_in_the_loop` key → `403 mode_not_allowed`. `false` (default) stages drafts for human approval."},"skipIfReplied":{"type":"boolean","default":true,"description":"When sending, do ONE targeted IMAP reply check per recipient right before send; skip if they already replied within `dedupeWindowHours` (outcome `skipped_replied`). Fails open on IMAP errors."},"dedupeWindowHours":{"type":"integer","default":72,"minimum":1,"maximum":720,"description":"Skip recipients already contacted by ANY composed/outreach email from this mailbox within this window (`reason: recently_contacted`). Also bounds the pre-send reply check."}}},"OutreachFinalRequest":{"allOf":[{"$ref":"#/components/schemas/OutreachCommonProps"},{"type":"object","required":["businessId","recipients","subject","body"],"properties":{"mode":{"type":"string","const":"final","description":"Optional; defaults to final."},"subject":{"type":"string","minLength":1,"maxLength":255,"description":"Subject template. `{{name}}` / `{{email}}` are substituted per recipient.","example":"Quick question, {{name}}"},"body":{"type":"string","minLength":1,"maxLength":50000,"description":"Body template. `{{name}}` / `{{email}}` substituted per recipient. Do not include the mailbox signature - it is appended automatically on send.","example":"Hi {{name}} - reaching out at {{email}}."}}}]},"OutreachGenerateRequest":{"allOf":[{"$ref":"#/components/schemas/OutreachCommonProps"},{"type":"object","required":["mode","businessId","recipients","brief"],"properties":{"mode":{"type":"string","const":"generate"},"brief":{"type":"string","minLength":1,"maxLength":4000,"description":"What the AI should write, per recipient.","example":"Introduce AutoEmail and ask for a 15-minute discovery call."},"tone":{"type":"string","minLength":1,"maxLength":500,"example":"direct, warm, concise"},"constraints":{"type":"string","minLength":1,"maxLength":4000,"description":"Hard constraints for the copy (e.g. 'under 120 words', 'no pricing claims')."},"subjectHint":{"type":"string","minLength":1,"maxLength":255,"description":"Optional steer for the generated subject line."},"model":{"type":"string","description":"Optional OpenRouter text model id. Defaults to the app's configured model.","example":"~google/gemini-flash-latest"}}}]},"OutreachResultItem":{"type":"object","additionalProperties":false,"required":["email","status"],"properties":{"email":{"type":"string","format":"email","description":"The recipient this outcome is for (as supplied in the request)."},"status":{"type":"string","enum":["sent_scheduled","pending_approval","skipped"],"description":"`sent_scheduled` (autonomous send queued async), `pending_approval` (draft created for human review), or `skipped`."},"reason":{"type":"string","enum":["daily_cap_reached","recently_contacted","invalid_email","generation_failed"],"description":"Present only when `status` is `skipped`."},"emailId":{"allOf":[{"$ref":"#/components/schemas/ConvexId"}],"description":"The created email row id (present for `sent_scheduled` and `pending_approval`). Query its `outreachOutcome` later via GET /emails for the async send result."}}},"OutreachBatchResult":{"type":"object","additionalProperties":false,"required":["batchId","businessId","mode","send","results","summary"],"properties":{"batchId":{"type":"string","description":"Server-generated id shared by every email row in this batch (queryable group).","example":"ob_1717200000000_a1b2c3d4"},"businessId":{"$ref":"#/components/schemas/ConvexId"},"mode":{"type":"string","enum":["final","generate"]},"send":{"type":"boolean","description":"Whether this batch was actually sent autonomously (true only for full_autonomous keys with send:true)."},"results":{"type":"array","items":{"$ref":"#/components/schemas/OutreachResultItem"},"description":"Per-recipient outcome. NOTE: when `send` is true, `sent_scheduled` means the SMTP send happens asynchronously after this response; the terminal outcome (`sent` | `skipped_replied` | `send_failed`) is recorded on the email row's `outreachOutcome` field and is queryable via GET /emails."},"summary":{"type":"object","additionalProperties":false,"required":["requested","accepted","skipped"],"properties":{"requested":{"type":"integer","description":"How many recipients were in the request."},"accepted":{"type":"integer","description":"How many were staged (drafted or scheduled for send) and consumed quota + daily cap."},"skipped":{"type":"integer","description":"How many were skipped (dedupe / daily cap / generation failure / invalid)."}}}}},"ThreadSummary":{"type":"object","properties":{"threadId":{"$ref":"#/components/schemas/ConvexId"},"businessId":{"$ref":"#/components/schemas/ConvexId"},"subject":{"type":"string"},"participants":{"type":"array","items":{"type":"string"}},"messageCount":{"type":"integer"},"lastMessageAt":{"type":"integer","description":"Unix epoch ms."},"lastSnippet":{"type":"string"},"hasUnread":{"type":"boolean"}}},"ThreadListResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ThreadSummary"}},"page":{"type":"integer"},"pageSize":{"type":"integer"},"total":{"type":"integer"},"totalPages":{"type":"integer"}}},"ThreadMessageSummary":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"direction":{"type":"string","enum":["inbound","outbound"]},"sender":{"type":"string"},"recipient":{"oneOf":[{"type":"string"},{"type":"null"}]},"receivedAt":{"type":"integer"},"sentAt":{"oneOf":[{"type":"integer"},{"type":"null"}]},"snippet":{"type":"string","description":"Up to 300 chars - NOT the full body."},"hasAttachments":{"type":"boolean"},"status":{"$ref":"#/components/schemas/EmailStatusValue"}}},"ThreadDetail":{"type":"object","properties":{"thread":{"$ref":"#/components/schemas/ThreadSummary"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/ThreadMessageSummary"}}}},"EmailPatchRequest":{"type":"object","description":"At least one of isRead/isStarred/status. status is restricted to allowed transitions.","minProperties":1,"properties":{"isRead":{"type":"boolean"},"isStarred":{"type":"boolean"},"status":{"type":"string","enum":["spam","trash","pending"]}}},"DraftVersion":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"version":{"type":"integer"},"body":{"type":"string"},"createdAt":{"type":"integer","description":"Unix epoch ms."}},"required":["id","version","body","createdAt"]},"AttachmentMeta":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"filename":{"type":"string"},"contentType":{"type":"string"},"size":{"type":"integer","description":"Bytes."},"isInline":{"type":"boolean"},"available":{"type":"boolean","description":"False for metadata-only rows (no stored blob)."}},"required":["id","filename","contentType","size","isInline","available"]},"EmailDetailAttachment":{"type":"object","description":"An attachment as returned inline on GET /emails/{id}. Same shape as AttachmentMeta except `id` is nullable: legacy inbound rows predating the attachments table have no downloadable blob and return `id: null` + `available: false`.","additionalProperties":false,"properties":{"id":{"oneOf":[{"$ref":"#/components/schemas/ConvexId"},{"type":"null"}],"description":"The attachment document id (use with GET /attachments/{id}/download-url), or null for a legacy metadata-only entry."},"filename":{"type":"string"},"contentType":{"type":"string"},"size":{"type":"integer","description":"Bytes."},"isInline":{"type":"boolean"},"available":{"type":"boolean","description":"True when a blob is stored and downloadable. False for metadata-only or legacy rows."}},"required":["id","filename","contentType","size","isInline","available"]},"Contact":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"businessId":{"$ref":"#/components/schemas/ConvexId"},"email":{"type":"string"},"firstName":{"oneOf":[{"type":"string"},{"type":"null"}]},"lastName":{"oneOf":[{"type":"string"},{"type":"null"}]},"company":{"oneOf":[{"type":"string"},{"type":"null"}]},"phone":{"oneOf":[{"type":"string"},{"type":"null"}]},"tags":{"type":"array","items":{"type":"string"}},"notes":{"oneOf":[{"type":"string"},{"type":"null"}]},"source":{"type":"string"},"createdAt":{"type":"integer"},"updatedAt":{"type":"integer"}}},"ContactListResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"page":{"type":"integer"},"pageSize":{"type":"integer"},"total":{"type":"integer"},"totalPages":{"type":"integer"}}},"CalendarEvent":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"businessId":{"$ref":"#/components/schemas/ConvexId"},"title":{"type":"string"},"startAt":{"type":"integer","description":"Unix epoch ms."},"endAt":{"type":"integer","description":"Unix epoch ms."},"location":{"oneOf":[{"type":"string"},{"type":"null"}]},"description":{"oneOf":[{"type":"string"},{"type":"null"}]},"color":{"oneOf":[{"type":"string"},{"type":"null"}]},"timezone":{"oneOf":[{"type":"string"},{"type":"null"}]},"sourceEmailId":{"oneOf":[{"$ref":"#/components/schemas/ConvexId"},{"type":"null"}]}}},"CalendarListResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CalendarEvent"}},"page":{"type":"integer"},"pageSize":{"type":"integer"},"total":{"type":"integer"},"totalPages":{"type":"integer"}}},"SpamSettings":{"type":"object","properties":{"spamThreshold":{"type":"integer","minimum":0,"maximum":100},"spamCustomRules":{"type":"string"}},"required":["spamThreshold","spamCustomRules"]},"SpamSettingsUpdate":{"type":"object","description":"At least one of spamThreshold / spamCustomRules.","minProperties":1,"properties":{"spamThreshold":{"type":"integer","minimum":0,"maximum":100},"spamCustomRules":{"type":"string","maxLength":10000}}},"Lesson":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"title":{"oneOf":[{"type":"string"},{"type":"null"}]},"content":{"type":"string"},"source":{"type":"string"},"createdAt":{"type":"integer"}}},"SpamLesson":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/ConvexId"},"content":{"type":"string"},"correctionType":{"type":"string","enum":["marked_spam","marked_not_spam"]},"createdAt":{"type":"integer"}}},"LessonsResponse":{"type":"object","properties":{"lessons":{"type":"array","items":{"$ref":"#/components/schemas/Lesson"}},"spamLessons":{"type":"array","items":{"$ref":"#/components/schemas/SpamLesson"}}},"required":["lessons","spamLessons"]},"Usage":{"type":"object","properties":{"periodStart":{"type":"integer","description":"Unix epoch ms."},"periodEnd":{"type":"integer","description":"Unix epoch ms."},"used":{"type":"integer"},"includedQuota":{"type":"integer"},"topUpQuota":{"type":"integer"},"remaining":{"type":"integer"}},"required":["periodStart","periodEnd","used","includedQuota","topUpQuota","remaining"]}}}}