openapi: 3.0.3
info:
  title: agent-cold-email API
  version: "0.1.0"
  description: >
    Agent-native cold-email infrastructure, live in production. 24 curated
    intents behind one bearer token per tenant (a subset — the /dashboard/*
    session + views endpoints — also accept an httpOnly session cookie minted
    by POST /dashboard/session, for the optional human dashboard SPA; see
    SPEC.md §19). Content generation is the caller's job; this API owns
    infrastructure provisioning, sequencing, isolation, and compliance
    guardrails. Real sending runs live in production (Gmail API, HTTPS/443);
    new accounts activate real sending through a short
    concierge step while self-serve activation rolls out — see the
    repository ROADMAP.md for exact current state. No deliverability or
    inbox-placement guarantees are made by this API.
  contact:
    name: EpiphanyMade
    email: support@epiphanymade.com
  license:
    name: MIT
servers:
  - url: https://agent-cold-email-api.yaakovscher.workers.dev
    description: Placeholder — substituted with the deployed API host post-deploy.

security:
  - bearerAuth: []

tags:
  - name: bootstrap
  - name: infrastructure
  - name: campaigns
  - name: inbox
  - name: account
  - name: dashboard
  - name: webhooks
  - name: byo-domains
  - name: leads
  - name: compliance

paths:
  /signup:
    post:
      operationId: signup
      tags: [bootstrap]
      summary: Create a tenant and mint a bearer token.
      description: >
        Unauthenticated bootstrap call. Always mints a demo-plan tenant in
        the current build (no live paid/Stripe checkout path yet) — a demo
        tenant is structurally incapable of reaching a real vendor adapter.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignupInput'
      responses:
        '201':
          description: Tenant created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenantId: { type: string, example: "ten_a1b2c3" }
                  token: { type: string, description: "Bearer token — store securely, shown once." }
                required: [tenantId, token]
        '400':
          $ref: '#/components/responses/ValidationError'

  /setup-infrastructure:
    post:
      operationId: setup_infrastructure
      tags: [infrastructure]
      summary: Provision branded lookalike domains and mailboxes; start warmup.
      description: >
        Asynchronous: returns 202 immediately. Poll GET /infrastructure-status
        for progress. In demo/free plans this runs entirely against a
        fault-injecting sandbox — no real domain purchase or mailbox
        provisioning occurs.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetupInfrastructureInput'
      responses:
        '202':
          description: Provisioning started.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /infrastructure-status:
    get:
      operationId: infrastructure_status
      tags: [infrastructure]
      summary: Provisioning + warmup progress, per-mailbox health, send-readiness.
      responses:
        '200':
          description: Current infrastructure status.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'

  /campaigns:
    post:
      operationId: launch_campaign
      tags: [campaigns]
      summary: Create and activate a campaign against a lead list.
      description: >
        The caller supplies the offer and sequence content — this API does
        not generate outreach copy.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LaunchCampaignInput'
      responses:
        '201':
          description: Campaign created.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      operationId: list_campaigns
      tags: [campaigns]
      summary: List every campaign for the tenant with id, name, status, and event counts.
      description: >
        No N+1: one query for the campaign rows, one GROUP BY for every
        campaign's event counts. A campaign with zero events still reports
        zero-filled counts (never missing keys).
      responses:
        '200':
          description: Campaign list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CampaignListItem' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /campaigns/{id}/results:
    get:
      operationId: campaign_results
      tags: [campaigns]
      summary: Sends, replies, bounces, and complaints for one campaign.
      parameters:
        - $ref: '#/components/parameters/CampaignId'
      responses:
        '200':
          description: Campaign results.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /campaigns/{id}/pause:
    post:
      operationId: pause
      tags: [campaigns]
      summary: Pause one campaign.
      parameters:
        - $ref: '#/components/parameters/CampaignId'
      responses:
        '200':
          description: Paused.
          content:
            application/json:
              schema:
                type: object
                properties: { paused: { type: boolean } }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /campaigns/pause-all:
    post:
      operationId: pause_all
      tags: [campaigns]
      summary: Pause every campaign for the authenticated tenant.
      responses:
        '200':
          description: All paused.
          content:
            application/json:
              schema:
                type: object
                properties: { pausedAll: { type: boolean } }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /metrics:
    get:
      operationId: metrics
      tags: [account]
      summary: Account-wide deliverability + warmup health.
      responses:
        '200':
          description: Metrics snapshot.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'

  /inbox:
    get:
      operationId: inbox
      tags: [inbox]
      summary: Unified reply inbox across all mailboxes for the tenant.
      description: >
        v2: cursor-paginated, filterable. A bare GET /inbox (no query params)
        preserves the exact pre-v2 shape — every thread, bounces/OOO
        included, page 1 of 50.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          schema: { type: string, minLength: 1, maxLength: 500 }
        - name: mailbox
          in: query
          schema: { type: string, minLength: 1, maxLength: 320 }
        - name: campaign
          in: query
          schema: { type: string, minLength: 1, maxLength: 200 }
        - name: label
          in: query
          schema: { type: string, minLength: 1, maxLength: 100 }
        - name: read
          in: query
          schema: { type: boolean }
        - name: include_nonreply
          in: query
          description: >
            Include bounces/OOO/other non-reply threads. Default true
            (matches the pre-v2 GET /inbox shape); the dashboard UI's
            default view sets this false.
          schema: { type: boolean, default: true }
        - name: archived
          in: query
          schema: { type: string, enum: [exclude, include, only], default: exclude }
      responses:
        '200':
          description: Inbox listing.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'

  /threads/{id}:
    get:
      operationId: thread
      tags: [inbox]
      summary: Full message history for one thread.
      parameters:
        - $ref: '#/components/parameters/ThreadId'
      responses:
        '200':
          description: Thread detail.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /threads/{id}/reply:
    post:
      operationId: reply
      tags: [inbox]
      summary: Send a reply on an existing thread.
      parameters:
        - $ref: '#/components/parameters/ThreadId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReplyInput'
      responses:
        '201':
          description: Reply sent.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /threads/{id}/mark:
    post:
      operationId: mark
      tags: [inbox]
      summary: Mark a thread read, unread, or archived.
      parameters:
        - $ref: '#/components/parameters/ThreadId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MarkInput'
      responses:
        '200':
          description: Marked.
          content:
            application/json:
              schema:
                type: object
                properties: { marked: { type: boolean } }
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /threads/{id}/label:
    post:
      operationId: label_thread
      tags: [inbox]
      summary: Set (or, with label null, clear) a triage label on an inbox thread.
      parameters:
        - $ref: '#/components/parameters/ThreadId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ThreadLabelInput'
      responses:
        '200':
          description: Label set/cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  threadId: { type: string }
                  label: { type: string, nullable: true }
                  source:
                    type: string
                    nullable: true
                    enum: [dashboard, mcp, api, system]
                required: [threadId, label, source]
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /activity:
    get:
      operationId: activity
      tags: [inbox]
      summary: Unified, chronological feed of campaign events + deliverability control-loop actions.
      description: >
        Merges the `events` table (sends/replies/bounces/complaints) with
        `deliverability_actions` (pause/throttle/rotate/replace-domain) into
        one cursor-paginated feed, one query.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          schema: { type: string, minLength: 1, maxLength: 500 }
        - name: kind
          in: query
          description: Omit for every kind (backward-compatible default).
          schema: { type: string, enum: [event, deliverability] }
      responses:
        '200':
          description: Activity feed page.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivityPage'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account:
    get:
      operationId: account
      tags: [account]
      summary: Usage, billing state, and quota for the tenant.
      responses:
        '200':
          description: Account snapshot.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'

  /dashboard/session:
    post:
      operationId: dashboard_login
      tags: [dashboard]
      summary: Exchange a tenant bearer token for an httpOnly dashboard session cookie.
      description: >
        Unauthenticated (there is no Authorization header at this point in
        the flow) — the pasted tenant bearer token travels in the JSON body
        instead and is verified here via the same resolver `requireAuth`
        uses. On success, sets an httpOnly/Secure/SameSite=Strict session
        cookie; the SPA never sees the bearer token or the session id in
        JS-readable storage.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DashboardSessionInput'
      responses:
        '200':
          description: Session cookie set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenantId: { type: string }
                required: [tenantId]
        '401':
          $ref: '#/components/responses/Unauthorized'

  /dashboard/logout:
    post:
      operationId: dashboard_logout
      tags: [dashboard]
      summary: Revoke the current dashboard session cookie.
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      responses:
        '200':
          description: Logged out.
          content:
            application/json:
              schema:
                type: object
                properties: { loggedOut: { type: boolean } }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /dashboard/views:
    get:
      operationId: get_dashboard
      tags: [dashboard]
      summary: List every saved dashboard view (summary — id, name, isDefault, rev, editedBy).
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      responses:
        '200':
          description: Saved-view summaries; lazy-seeds the `default` view for a brand-new tenant.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/DashboardViewSummary' }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: configure_dashboard_create
      tags: [dashboard]
      summary: Create a dashboard saved view.
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DashboardViewCreateInput'
      responses:
        '201':
          description: View created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DashboardViewDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Invalid/unknown widget type or props (syntactically valid JSON, semantically invalid).
          content:
            application/json:
              schema:
                type: object
                properties: { error: { type: string }, issues: { type: array, items: { type: object } } }

  /dashboard/views/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: get_dashboard_view
      tags: [dashboard]
      summary: Fetch one saved view's full layout + rev.
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      responses:
        '200':
          description: View detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DashboardViewDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      operationId: configure_dashboard_update
      tags: [dashboard]
      summary: Update a saved view's layout (rev-CAS) and/or rename it.
      description: >
        `rev` must match the view's current rev; a stale rev returns 409
        with a structured conflict (currentRev + currentLayout) so the
        caller can rebase and retry. Omitting `name` leaves it unchanged.
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DashboardViewUpdateInput'
      responses:
        '200':
          description: View updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DashboardViewDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Stale rev — structured conflict to rebase onto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  currentRev: { type: integer }
                  currentLayout: { $ref: '#/components/schemas/DashboardLayout' }
                required: [error, currentRev, currentLayout]
        '422':
          description: Invalid/unknown widget type or props.
          content:
            application/json:
              schema:
                type: object
                properties: { error: { type: string }, issues: { type: array, items: { type: object } } }
    delete:
      operationId: configure_dashboard_delete
      tags: [dashboard]
      summary: Delete a saved view (cannot delete the default view or the last remaining view).
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties: { deleted: { type: boolean } }
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /dashboard/views/{id}/default:
    post:
      operationId: configure_dashboard_promote
      tags: [dashboard]
      summary: Promote a saved view to be the tenant's default.
      security:
        - bearerAuth: []
        - dashboardSessionCookie: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Every saved-view summary, with the promoted view's isDefault now true.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/DashboardViewSummary' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /webhook-subscriptions:
    get:
      operationId: get_webhooks
      tags: [webhooks]
      summary: List every outbound webhook subscription for the tenant.
      description: >
        Secrets are never returned on reads — they are shown once at
        create/rotate. Use GET /webhook-subscriptions/{id} for one
        subscription plus its recent delivery and attempt log.
      responses:
        '200':
          description: Webhook subscription summaries.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WebhookSummary' }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: configure_webhook_create
      tags: [webhooks]
      summary: Create an outbound webhook subscription.
      description: >
        Push reply, bounce, soft_bounce, and complaint events to your own
        HTTPS endpoint instead of polling GET /activity. Deliveries are
        signed `X-Coldrig-Signature: sha256=HMAC-SHA256(secret, raw body)`.
        URLs must be https and resolve to a public host — private/link-
        local/metadata IPs are rejected, re-validated on every delivery.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreateInput'
      responses:
        '201':
          description: Subscription created — the response carries the signing secret once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreateResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /webhook-subscriptions/{id}:
    parameters:
      - $ref: '#/components/parameters/WebhookSubscriptionId'
    get:
      operationId: get_webhook
      tags: [webhooks]
      summary: One webhook subscription plus its recent delivery and attempt log.
      responses:
        '200':
          description: Subscription detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      operationId: configure_webhook_update
      tags: [webhooks]
      summary: Update a webhook subscription — url, event filter, active flag, or rotate the secret.
      description: >
        At least one of url, eventTypes, secret, or active must be present.
        active:true re-enables a subscription the delivery pump
        auto-disabled after 5 consecutive terminal failures; active:false
        pauses delivery without deleting. Rotating the secret returns it
        once in the response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookUpdateInput'
      responses:
        '200':
          description: Subscription updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookUpdateResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: configure_webhook_delete
      tags: [webhooks]
      summary: Delete a webhook subscription.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties: { deleted: { type: boolean } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /byo-domains:
    get:
      operationId: get_byo_domains
      tags: [byo-domains]
      summary: List every bring-your-own domain for the tenant.
      description: >
        SPEC.md §20. Summary rows only — use GET /byo-domains/{id} for the
        full intake detail (pre-flight scan, abuse verdict, consent status).
      responses:
        '200':
          description: BYO domain summaries.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ByoDomainSummary' }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: register_byo_domain
      tags: [byo-domains]
      summary: Register a bring-your-own domain and run the pre-flight intake scan.
      description: >
        Runs the live-infra scan (SPEC.md §20.1), the abuse gate (§20.3),
        and the reputation ladder (§20.5), returning the starting
        byoStatus. A live-infra hit hard-refuses NS delegation and falls
        back to records_to_apply; a primary domain is always
        records_to_apply, never we_manage_zone. Real DNS delegation and
        live reputation checks activate per-tenant the same way real
        sending does — this endpoint is reachable today, but registering a
        domain does not itself provision anything real until the tenant is
        activated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterByoDomainInput'
      responses:
        '201':
          description: Domain registered — intake started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ByoDomainRecord'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /byo-domains/{id}:
    get:
      operationId: get_byo_domain
      tags: [byo-domains]
      summary: One BYO domain's full intake detail.
      parameters:
        - $ref: '#/components/parameters/ByoDomainId'
      responses:
        '200':
          description: BYO domain detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ByoDomainRecord'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /byo-domains/{id}/poll-dns:
    post:
      operationId: poll_byo_domain_dns
      tags: [byo-domains]
      summary: Re-check DNS delegation/records for a domain in pending_dns.
      description: >
        SPEC.md §20.1's poll-verify: advances pending_dns → active on
        success, or → abandoned after a 7-day idle timeout with no
        verification. A no-op (returns current state) once past
        pending_dns.
      parameters:
        - $ref: '#/components/parameters/ByoDomainId'
      responses:
        '200':
          description: Poll result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PollDnsResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /byo-domains/{id}/consent:
    post:
      operationId: acknowledge_byo_consent
      tags: [byo-domains]
      summary: Acknowledge the primary-domain risk disclosure (SPEC.md §20.4).
      description: >
        Required before a primary-domain intake can proceed past
        pending_consent. acknowledged must be the literal true — there is
        no default or implied acknowledgment. Idempotent past
        pending_consent. The acknowledgment documents informed consent; it
        does not itself remove the business's exposure or substitute for
        the technical safeguards in §20.1/§20.2.
      parameters:
        - $ref: '#/components/parameters/ByoDomainId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AcknowledgeByoConsentInput'
      responses:
        '200':
          description: Consent recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ByoDomainRecord'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /byo-domains/{id}/managed-mailboxes:
    post:
      operationId: request_managed_byo_mailboxes
      tags: [byo-domains]
      summary: Request platform-provisioned mailboxes on an already-active BYO domain.
      description: >
        SPEC.md §20.6 shape (a) — the primary build target: the vendor
        provisions new mailboxes under the customer's own domain, reusing
        the same provisioning path as the lookalike-domain flow. Requires
        the domain to already be active (past DNS verification). No
        pricing is published for BYO-connected mailboxes specifically.
      parameters:
        - $ref: '#/components/parameters/ByoDomainId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RequestManagedByoMailboxesInput'
      responses:
        '201':
          description: Mailboxes provisioned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ManagedMailboxesResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /byo-domains/{id}/connect-mailbox:
    post:
      operationId: connect_byo_mailbox
      tags: [byo-domains]
      summary: Declare an existing mailbox connection on a BYO domain.
      description: >
        SPEC.md §20.6 — bypasses vendor provisioning entirely: the caller
        already holds the OAuth refresh token or SMTP+IMAP app password for
        a mailbox that already exists. This declares the mailbox the
        caller is connecting — it is not a claim about this platform's own
        outbound send transport, which is Gmail API over HTTPS/443 in
        production today.
      parameters:
        - $ref: '#/components/parameters/ByoDomainId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConnectByoMailboxInput'
      responses:
        '201':
          description: Mailbox connection recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectByoMailboxResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /leads:
    get:
      operationId: list_leads
      tags: [leads]
      summary: List/export leads with their contact-level disposition, cursor-paginated.
      description: >
        SPEC.md §22. Also the export surface — paginate to dump the full
        book of business as JSON; there is no separate CSV endpoint.
      parameters:
        - name: campaign
          in: query
          schema: { type: string, minLength: 1, maxLength: 200 }
        - name: interestStatus
          in: query
          schema: { $ref: '#/components/schemas/LeadInterestStatus' }
        - name: suppressed
          in: query
          schema: { type: boolean }
        - name: replied
          in: query
          schema: { type: boolean }
        - name: cursor
          in: query
          schema: { type: string, minLength: 1, maxLength: 500 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Lead list page.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LeadListPage'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /leads/suppress:
    post:
      operationId: suppress_lead
      tags: [leads]
      summary: Permanently suppress an email address tenant-wide.
      description: >
        SPEC.md §22. The manual/free-text "stop emailing me" path for
        opt-outs the strict typed-unsubscribe matcher misses. Cancels every
        pending send and marks every campaign-lead row for this email
        suppressed. There is no un-suppress endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuppressLeadInput'
      responses:
        '200':
          description: Suppressed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuppressLeadResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /leads/disposition:
    post:
      operationId: update_lead
      tags: [leads]
      summary: Record a contact-level lead disposition (interest status, notes, tags).
      description: >
        SPEC.md §22. Upserts the durable, contact-level lead_dispositions
        row keyed by email — visible across every campaign that lists the
        contact. A partial patch: only the fields you pass are changed; at
        least one of interestStatus, notes, or tags is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateLeadInput'
      responses:
        '200':
          description: Disposition upserted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LeadDispositionView'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /unsubscribe:
    get:
      operationId: unsubscribe_confirm_page
      tags: [compliance]
      summary: Render a human confirm page for the in-body one-click unsubscribe link.
      description: >
        UNAUTHENTICATED — the signed `tenant`/`email`/`sig` triplet in the
        query string (minted server-side into every sequence email's
        List-Unsubscribe header and in-body opt-out link) is the credential;
        there is no bearer token on this route. GET alone never suppresses
        anything — it renders an HTML confirm page whose own form POSTs back
        to this same URL. Intended for a human clicking the link inside the
        email body, in a browser; a mail client's own automatic RFC 8058
        one-click action POSTs directly (see below) and never hits this GET.
      security: []
      parameters:
        - $ref: '#/components/parameters/UnsubscribeTenant'
        - $ref: '#/components/parameters/UnsubscribeEmail'
        - $ref: '#/components/parameters/UnsubscribeSig'
      responses:
        '200':
          description: Confirm page (not yet unsubscribed).
          content:
            text/html:
              schema: { type: string }
        '400':
          description: Missing/invalid query params or an invalid/tampered signature.
          content:
            text/html:
              schema: { type: string }
    post:
      operationId: unsubscribe_confirm
      tags: [compliance]
      summary: Perform the RFC 8058 one-click unsubscribe (or confirm from the GET page).
      description: >
        UNAUTHENTICATED, same credential as the GET above. This is the exact
        URL a compliant mail client POSTs to automatically for its own native
        "Unsubscribe" action (`List-Unsubscribe-Post: List-Unsubscribe=One-Click`)
        — no human interaction on this page in that flow — and is also what
        the GET confirm page's own form submits to when a human clicks
        through the in-body link instead. Suppresses the address tenant-wide
        immediately (visible to the very next scheduling tick, not a CAN-SPAM-
        style grace window) and cancels every pending scheduled send for every
        lead sharing that email across every campaign. Idempotent: a repeat
        call with the same valid token always 200s.
      security: []
      parameters:
        - $ref: '#/components/parameters/UnsubscribeTenant'
        - $ref: '#/components/parameters/UnsubscribeEmail'
        - $ref: '#/components/parameters/UnsubscribeSig'
      responses:
        '200':
          description: Unsubscribed (or already was — idempotent).
          content:
            text/html:
              schema: { type: string }
        '400':
          description: Missing/invalid query params or an invalid/tampered signature — no suppression applied.
          content:
            text/html:
              schema: { type: string }
        '413':
          description: Request body too large.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Token returned by POST /signup. One token = one isolated tenant.
    dashboardSessionCookie:
      type: apiKey
      in: cookie
      name: cs_dashboard_session
      description: >
        httpOnly session cookie minted by POST /dashboard/session (30-day
        TTL). Alternative to bearerAuth for /dashboard/* routes only, used by
        the optional human dashboard SPA. A cookie-authed mutating request
        (any method other than GET/HEAD) must also send the
        `X-Coldstart-Client: dashboard` header (global CSRF guard,
        SPEC.md §19.1) — bearer-authed requests are exempt.

  parameters:
    CampaignId:
      name: id
      in: path
      required: true
      description: >
        The MCP tool `campaign_results` takes this same identifier as a
        `campaignId` argument (there is no URL path in MCP tool calls).
      schema: { type: string }
    ThreadId:
      name: id
      in: path
      required: true
      schema: { type: string }
    WebhookSubscriptionId:
      name: id
      in: path
      required: true
      description: >
        The MCP tools `get_webhooks` and `configure_webhook` take this same
        identifier as an `id` argument (there is no URL path in MCP tool
        calls).
      schema: { type: string }
    ByoDomainId:
      name: id
      in: path
      required: true
      description: >
        The MCP tools `get_byo_domains` and `configure_byo_domain` take
        this same identifier as an `id` argument (there is no URL path in
        MCP tool calls).
      schema: { type: string }
    UnsubscribeTenant:
      name: tenant
      in: query
      required: true
      description: The tenant id this signed opt-out link belongs to.
      schema: { type: string, minLength: 1, maxLength: 200 }
    UnsubscribeEmail:
      name: email
      in: query
      required: true
      description: The recipient address this signed opt-out link covers.
      schema: { type: string, minLength: 1, maxLength: 320, format: email }
    UnsubscribeSig:
      name: sig
      in: query
      required: true
      description: >
        HMAC-SHA256 over `tenant:email`, keyed off a server secret. There is
        no per-send row backing this — the signature itself is the entire
        credential, and it never expires (an opt-out doesn't need to).
      schema: { type: string, minLength: 1, maxLength: 128 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Optional. Resend the SAME key on retry to avoid duplicate campaigns:
        the first call runs, a replay with the same key returns the recorded
        result without re-executing. Note the mechanism differs by
        transport — REST takes this as the `Idempotency-Key` header (this
        parameter); the MCP `launch_campaign` tool takes the equivalent
        value as an `idempotencyKey` argument field instead, since MCP
        tool calls have no separate header channel.
      schema: { type: string, minLength: 1, maxLength: 200 }

  responses:
    ValidationError:
      description: Request body failed schema validation.
      content:
        application/json:
          schema:
            type: object
            properties: { error: { type: string } }
    Unauthorized:
      description: Missing, invalid, or inactive bearer token.
      content:
        application/json:
          schema:
            type: object
            properties: { error: { type: string } }
    NotFound:
      description: Resource not found, or not owned by the authenticated tenant.
      content:
        application/json:
          schema:
            type: object
            properties: { error: { type: string } }

  schemas:
    SignupInput:
      type: object
      required: [brand, contactEmail]
      properties:
        brand: { type: string, minLength: 1, maxLength: 200 }
        contactEmail: { type: string, format: email }

    SetupInfrastructureInput:
      type: object
      required: [brand, primaryDomain, domains, inboxesEach, persona, physicalAddress, senderIdentity]
      properties:
        brand: { type: string, minLength: 1, maxLength: 200 }
        primaryDomain: { type: string, minLength: 3, maxLength: 253, example: "acme.com" }
        domains: { type: integer, minimum: 1, maximum: 20, description: "Number of lookalike domains to provision." }
        inboxesEach: { type: integer, minimum: 1, maximum: 10, description: "Mailboxes to provision per domain." }
        persona: { type: string, minLength: 1, maxLength: 200 }
        physicalAddress:
          type: string
          minLength: 1
          maxLength: 500
          description: CAN-SPAM-required physical postal address, injected into every message footer.
        senderIdentity:
          type: string
          minLength: 1
          maxLength: 200
          description: Legal sender identity captured at setup, injected into every message footer.

    SequenceStepInput:
      type: object
      required: [step, subject, body, delayDays]
      properties:
        step: { type: integer, minimum: 1 }
        subject: { type: string, minLength: 1, maxLength: 300 }
        body: { type: string, minLength: 1, maxLength: 20000 }
        delayDays: { type: integer, minimum: 0, maximum: 60 }

    LeadInput:
      type: object
      required: [email, firstName]
      properties:
        email: { type: string, format: email }
        firstName: { type: string, minLength: 1, maxLength: 200 }
        company: { type: string, maxLength: 200, default: "" }

    LaunchCampaignInput:
      type: object
      required: [name, offer, leads, sequence]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        offer: { type: string, minLength: 1, maxLength: 2000 }
        leads:
          type: array
          minItems: 1
          maxItems: 5000
          items: { $ref: '#/components/schemas/LeadInput' }
        sequence:
          type: array
          minItems: 1
          maxItems: 10
          items: { $ref: '#/components/schemas/SequenceStepInput' }
        timezone: { type: string, default: "UTC" }
        sendWindow:
          type: object
          properties:
            startHour: { type: integer, minimum: 0, maximum: 23 }
            endHour: { type: integer, minimum: 0, maximum: 23 }
          default: { startHour: 0, endHour: 23 }
        stopOnReply: { type: boolean, default: true }

    ReplyInput:
      type: object
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 20000 }

    MarkInput:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [read, unread, archived] }

    ThreadLabelInput:
      type: object
      properties:
        label:
          type: string
          minLength: 1
          maxLength: 100
          nullable: true
          default: null
          description: Free-form (a UI-recommended taxonomy, not a server-enforced enum). null clears the label.

    EventCounts:
      type: object
      properties:
        sent: { type: integer }
        reply: { type: integer }
        bounce: { type: integer, description: "HARD (permanent 5.x.x) bounces." }
        complaint: { type: integer }
        unsubscribe: { type: integer }
        failed: { type: integer }
        soft_bounce: { type: integer, description: "SOFT (transient 4.x.x) bounces — tallied, not a permanent suppression." }
      required: [sent, reply, bounce, complaint, unsubscribe, failed, soft_bounce]

    CampaignListItem:
      type: object
      required: [campaignId, name, status, counts]
      properties:
        campaignId: { type: string }
        name: { type: string }
        status: { type: string }
        counts: { $ref: '#/components/schemas/EventCounts' }

    ActivityItem:
      type: object
      required: [id, kind, label, ts, target, detail]
      properties:
        id: { type: string }
        kind: { type: string, enum: [event, deliverability] }
        label: { type: string }
        ts: { type: integer }
        target: { type: string, nullable: true }
        detail: { type: object, additionalProperties: true }

    ActivityPage:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ActivityItem' }
        nextCursor: { type: string, nullable: true }

    DashboardSessionInput:
      type: object
      required: [token]
      properties:
        token: { type: string, minLength: 1, maxLength: 500, description: "The tenant bearer token from POST /signup." }

    # Widget layout (SPEC.md §19.3) — the widget registry's per-type `props`
    # are validated server-side against 8 discriminated schemas
    # (packages/shared/src/dashboard.ts); documented here at the outer-shape
    # level only (type/gridPos/visible + a generic props object) to avoid
    # duplicating that full union into this spec.
    DashboardLayout:
      type: object
      required: [schemaVersion, widgets]
      properties:
        schemaVersion: { type: integer, enum: [1] }
        widgets:
          type: array
          maxItems: 50
          items:
            type: object
            required: [id, type, gridPos, visible, props]
            properties:
              id: { type: string, minLength: 1, maxLength: 100 }
              type:
                type: string
                enum: [kpi_row, mailbox_health, campaign_performance, activity_feed, inbox_preview, agent_log, agent_note, quota_usage]
              gridPos:
                type: object
                required: [x, y, w, h]
                properties:
                  x: { type: integer, minimum: 0, maximum: 11 }
                  y: { type: integer, minimum: 0, maximum: 1000 }
                  w: { type: integer, minimum: 1, maximum: 12 }
                  h: { type: integer, minimum: 1, maximum: 20 }
              visible: { type: boolean, default: true }
              props: { type: object, additionalProperties: true }

    DashboardViewSummary:
      type: object
      required: [id, name, isDefault, rev, editedBy, editedByNote, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        isDefault: { type: boolean }
        rev: { type: integer }
        editedBy: { type: string, enum: [dashboard, mcp, api, system] }
        editedByNote: { type: string, nullable: true }
        updatedAt: { type: string }

    DashboardViewDetail:
      allOf:
        - $ref: '#/components/schemas/DashboardViewSummary'
        - type: object
          required: [layout, createdAt]
          properties:
            layout: { $ref: '#/components/schemas/DashboardLayout' }
            createdAt: { type: string }

    DashboardViewCreateInput:
      type: object
      required: [name, layout]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        layout: { $ref: '#/components/schemas/DashboardLayout' }
        note: { type: string, maxLength: 2000 }

    DashboardViewUpdateInput:
      type: object
      required: [rev, layout]
      properties:
        rev: { type: integer, minimum: 1, description: "Must match the view's current rev (rev-CAS)." }
        layout: { $ref: '#/components/schemas/DashboardLayout' }
        name: { type: string, minLength: 1, maxLength: 200, description: "Optional rename; omit to leave unchanged." }
        note: { type: string, maxLength: 2000 }

    WebhookEventType:
      type: string
      enum: [reply, bounce, soft_bounce, complaint]
      description: "reply = genuine reply; bounce = HARD (permanent) bounce; soft_bounce = transient bounce; complaint = spam complaint."

    WebhookCreateInput:
      type: object
      required: [url, eventTypes]
      properties:
        url:
          type: string
          minLength: 1
          maxLength: 2048
          description: >
            HTTPS endpoint that receives event POSTs. Must be https and
            resolve to a public host — private/link-local/metadata IPs
            are rejected.
        eventTypes:
          type: array
          minItems: 1
          maxItems: 4
          items: { $ref: '#/components/schemas/WebhookEventType' }
          description: "Which event types to receive. At least one."
        secret:
          type: string
          minLength: 16
          maxLength: 200
          description: >
            Optional HMAC signing secret (>=16 chars). Omit to have the
            server generate one, returned once on create.
        active: { type: boolean, default: true, description: "Whether the subscription delivers immediately. Defaults to true." }

    WebhookUpdateInput:
      type: object
      description: "Every field optional; at least one of url, eventTypes, secret, or active must be present."
      properties:
        url: { type: string, minLength: 1, maxLength: 2048 }
        eventTypes:
          type: array
          minItems: 1
          maxItems: 4
          items: { $ref: '#/components/schemas/WebhookEventType' }
        secret:
          type: string
          minLength: 16
          maxLength: 200
          description: "Rotates the signing secret; the new value is returned once in the response."
        active:
          type: boolean
          description: >
            Set false to pause delivery without deleting; set true to
            resume (also re-enables an auto-disabled subscription).

    WebhookSummary:
      type: object
      required: [id, url, eventTypes, active, status, disabledReason, consecutiveFailures, createdAt, updatedAt]
      properties:
        id: { type: string }
        url: { type: string }
        eventTypes:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventType' }
        active: { type: boolean }
        status: { type: string, enum: [active, disabled] }
        disabledReason: { type: string, nullable: true }
        consecutiveFailures: { type: integer }
        createdAt: { type: integer }
        updatedAt: { type: integer }

    WebhookCreateResponse:
      allOf:
        - $ref: '#/components/schemas/WebhookSummary'
        - type: object
          required: [secret]
          properties:
            secret: { type: string, description: "HMAC signing secret — shown once on create, never returned on reads." }

    WebhookUpdateResponse:
      allOf:
        - $ref: '#/components/schemas/WebhookSummary'
        - type: object
          properties:
            secret: { type: string, description: "Present only when this update rotated the secret; shown once." }

    WebhookDeliveryView:
      type: object
      required: [id, eventId, eventType, status, attempts, lastStatusCode, lastError, nextAttemptAt, createdAt, lastAttemptAt, deliveredAt]
      properties:
        id: { type: string }
        eventId: { type: string }
        eventType: { type: string }
        status: { type: string }
        attempts: { type: integer }
        lastStatusCode: { type: integer, nullable: true }
        lastError: { type: string, nullable: true }
        nextAttemptAt: { type: integer }
        createdAt: { type: integer }
        lastAttemptAt: { type: integer, nullable: true }
        deliveredAt: { type: integer, nullable: true }

    WebhookAttemptView:
      type: object
      required: [deliveryId, attemptNo, ok, statusCode, error, ts]
      properties:
        deliveryId: { type: string }
        attemptNo: { type: integer }
        ok: { type: boolean }
        statusCode: { type: integer, nullable: true }
        error: { type: string, nullable: true }
        ts: { type: integer }

    WebhookDetail:
      type: object
      required: [subscription, recentDeliveries, recentAttempts]
      properties:
        subscription: { $ref: '#/components/schemas/WebhookSummary' }
        recentDeliveries:
          type: array
          items: { $ref: '#/components/schemas/WebhookDeliveryView' }
        recentAttempts:
          type: array
          items: { $ref: '#/components/schemas/WebhookAttemptView' }

    DomainRelationshipInput:
      type: string
      enum: [fresh_standalone, subdomain_of_primary, is_primary]
      description: >
        fresh_standalone = brand-new domain, no history; subdomain_of_primary
        = a subdomain of the tenant's existing primary domain;
        is_primary = the tenant's own flagship business domain.

    RegisterByoDomainInput:
      type: object
      required: [domain, domainRelationship]
      properties:
        domain: { type: string, minLength: 3, maxLength: 253 }
        domainRelationship: { $ref: '#/components/schemas/DomainRelationshipInput' }

    AcknowledgeByoConsentInput:
      type: object
      required: [acknowledged]
      properties:
        acknowledged:
          type: boolean
          enum: [true]
          description: >
            Must be the literal true — SPEC.md §20.4's separate, unbundled
            risk acknowledgment.

    RequestManagedByoMailboxesInput:
      type: object
      required: [count]
      properties:
        count: { type: integer, minimum: 1, maximum: 10 }
        personaSlug: { type: string, minLength: 1, maxLength: 50, description: "Optional; defaults to a slug of the domain." }

    ConnectByoMailboxSmtpInput:
      type: object
      required: [kind, host, port, secure, user, pass]
      properties:
        kind: { type: string, enum: [smtp] }
        host: { type: string }
        port: { type: integer }
        secure: { type: boolean }
        user: { type: string }
        pass: { type: string }

    ConnectByoMailboxGmailInput:
      type: object
      required: [kind, clientId, clientSecret, refreshToken]
      properties:
        kind: { type: string, enum: [gmail_api] }
        clientId: { type: string }
        clientSecret: { type: string }
        refreshToken: { type: string }

    ConnectByoMailboxGraphInput:
      type: object
      required: [kind, mode, tenantId, clientId, clientSecret]
      properties:
        kind: { type: string, enum: [ms_graph] }
        mode: { type: string, enum: [delegated, app_only] }
        tenantId: { type: string }
        clientId: { type: string }
        clientSecret: { type: string }
        refreshToken: { type: string, description: "Optional for app_only mode." }

    ConnectByoMailboxInput:
      type: object
      required: [email, transport]
      properties:
        email: { type: string, format: email }
        transport:
          description: >
            The mailbox connection the caller already holds — declares an
            EXISTING credential the caller supplies, not an OAuth flow this
            API initiates. This describes the mailbox being connected; it
            is not a claim about this platform's own outbound send
            transport, which is Gmail API over HTTPS/443 in production
            today.
          oneOf:
            - $ref: '#/components/schemas/ConnectByoMailboxSmtpInput'
            - $ref: '#/components/schemas/ConnectByoMailboxGmailInput'
            - $ref: '#/components/schemas/ConnectByoMailboxGraphInput'
          discriminator:
            propertyName: kind

    ByoDomainSummary:
      type: object
      required: [domainId, domain, isPrimary, dnsMode, byoStatus, breakerTier, reputationBranch, mailboxCount]
      properties:
        domainId: { type: string }
        domain: { type: string }
        isPrimary: { type: boolean }
        dnsMode: { type: string, enum: [we_manage_zone, records_to_apply] }
        byoStatus: { type: string, enum: [pending_kyc, pending_consent, pending_dns, active, rejected, abandoned] }
        breakerTier: { type: string, enum: [standard, elevated, primary] }
        reputationBranch: { type: string, nullable: true }
        mailboxCount: { type: integer }

    ByoDomainRecord:
      type: object
      required: [domainId, domain, isPrimary, dnsMode, byoStatus, breakerTier, reputationBranch, scan, abuseVerdict, consentAcknowledged]
      properties:
        domainId: { type: string }
        domain: { type: string }
        isPrimary: { type: boolean }
        dnsMode: { type: string, enum: [we_manage_zone, records_to_apply] }
        byoStatus: { type: string, enum: [pending_kyc, pending_consent, pending_dns, active, rejected, abandoned] }
        breakerTier: { type: string, enum: [standard, elevated, primary] }
        reputationBranch: { type: string, nullable: true }
        scan: { type: object, additionalProperties: true, description: "Pre-flight live-infra scan result (SPEC.md §20.1)." }
        abuseVerdict: { type: string, description: "SPEC.md §20.3 abuse-gate verdict." }
        consentAcknowledged: { type: boolean }

    PollDnsResult:
      type: object
      required: [domainId, byoStatus, verified, checksSoFar]
      properties:
        domainId: { type: string }
        byoStatus: { type: string, enum: [pending_kyc, pending_consent, pending_dns, active, rejected, abandoned] }
        verified: { type: boolean }
        checksSoFar: { type: integer }

    ManagedMailboxesResult:
      type: object
      required: [mailboxEmails]
      properties:
        mailboxEmails:
          type: array
          items: { type: string }

    ConnectByoMailboxResult:
      type: object
      required: [mailboxId, email, transportKind]
      properties:
        mailboxId: { type: string }
        email: { type: string }
        transportKind: { type: string }

    LeadInterestStatus:
      type: string
      enum: [none, interested, meeting_booked, not_now, not_interested, bad_fit, out_of_office, wrong_person]
      description: >
        SPEC.md §22. out_of_office reschedules the follow-up past the
        return date instead of treating silence as disinterest;
        wrong_person drives a referral ask. do_not_contact is deliberately
        not a member — that intent routes to POST /leads/suppress instead.

    SuppressLeadInput:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        reason:
          type: string
          enum: [manual]
          default: manual
          description: >
            Pinned to "manual" — the only value an external caller can
            honestly claim. bounce/soft_bounce/complaint/unsubscribe are
            exclusively system-derived elsewhere.
        note: { type: string, maxLength: 2000, description: "Accepted for schema symmetry; not persisted (suppressions carries no note column)." }

    SuppressLeadResult:
      type: object
      required: [suppressed, alreadySuppressed]
      properties:
        suppressed: { type: boolean, enum: [true] }
        alreadySuppressed: { type: boolean }

    UpdateLeadInput:
      type: object
      required: [email]
      description: "At least one of interestStatus, notes, or tags must be present — a call with none has nothing to persist."
      properties:
        email: { type: string, format: email }
        interestStatus: { $ref: '#/components/schemas/LeadInterestStatus' }
        notes: { type: string, maxLength: 5000 }
        tags:
          type: array
          maxItems: 50
          items: { type: string, minLength: 1, maxLength: 100 }

    LeadDispositionView:
      type: object
      required: [email, interestStatus, notes, tags, source, updatedAt]
      properties:
        email: { type: string }
        interestStatus: { $ref: '#/components/schemas/LeadInterestStatus' }
        notes: { type: string }
        tags:
          type: array
          items: { type: string }
        source: { type: string, enum: [dashboard, mcp, api, system] }
        updatedAt: { type: integer }

    LeadListPage:
      type: object
      required: [leads, nextCursor]
      properties:
        leads:
          type: array
          items:
            type: object
            required: [leadId, email, firstName, company, campaignId, campaignName, globalStatus, interestStatus, notes, tags, suppressed, lastEventType, lastEventTs, createdAt]
            properties:
              leadId: { type: string }
              email: { type: string }
              firstName: { type: string }
              company: { type: string }
              campaignId: { type: string }
              campaignName: { type: string }
              globalStatus: { type: string }
              interestStatus: { $ref: '#/components/schemas/LeadInterestStatus' }
              notes: { type: string }
              tags:
                type: array
                items: { type: string }
              suppressed: { type: boolean }
              lastEventType: { type: string, nullable: true }
              lastEventTs: { type: integer, nullable: true }
              createdAt: { type: integer }
        nextCursor: { type: string, nullable: true }
