openapi: 3.1.0
info:
  title: mailsocket API
  version: "1"
  description: |
    The mailsocket v1 REST API — ephemeral inboxes, message retrieval, and the
    "wait for the OTP / magic link" long-poll primitive.

    The API is served under the base path `/api/v1` (every path below is
    relative to that base). It is authenticated ONLY by an
    `Authorization: Bearer ms_live_...` header; dashboard session cookies are
    deliberately ignored.

    This document describes the API exactly as implemented. It is validated in
    CI (parse + structural asserts) so it cannot silently rot; see
    `backend/apps/api/tests/test_openapi.py`.
  contact:
    name: mailsocket
    url: https://mailsocket.app
servers:
  - url: https://dash.mailsocket.app/api/v1
    description: Production API (base path `/api/v1`)

security:
  - bearerAuth: []

tags:
  - name: inboxes
    description: Ephemeral inbox management.
  - name: messages
    description: Message retrieval and filtering.
  - name: agents
    description: Zero-signup AI-agent key minting.
  - name: verify
    description: Safe-mode email address verification.
  - name: domains
    description: |
      Custom domain (BYOD) management — Phase 3 Stage A. Behind the
      `ENABLE_BYOD` feature flag and the Business-plan `custom_domains`
      entitlement. Adding/verifying a domain here does not yet cause any
      mail to route to it (Stage B).
  - name: outbound
    description: |
      Outbound email send — W5 / Batch 2. Behind the `ENABLE_OUTBOUND`
      feature flag (off by default; every path 404s when off). Business
      plan only, requires a verified + DKIM-verified custom domain.

paths:
  /inboxes:
    get:
      tags: [inboxes]
      summary: List inboxes
      description: |
        Returns the caller's non-deleted inboxes, newest first, with cursor
        pagination. Every response carries the `X-RateLimit-*` headers.
      operationId: listInboxes
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/cursor"
      responses:
        "200":
          description: A page of inboxes.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboxListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [inboxes]
      summary: Create an inbox
      description: |
        Creates a new inbox with an optional `label`. Returns `201` with a
        `Location` header pointing at the new inbox, or `409` when the plan
        limit is reached (`plan_limit_reached`) or after allocation retries are
        exhausted (`conflict`).
      operationId: createInbox
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InboxWrite"
      responses:
        "201":
          description: Inbox created.
          headers:
            Location:
              description: Path of the new inbox (e.g. `/api/v1/inboxes/inbox_...`).
              schema:
                type: string
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Inbox"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: |
            Plan limit reached (`plan_limit_reached`), custom `routing_key`
            already claimed by another account (`slug_taken`), or allocation
            conflict (`conflict`).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                plan_limit_reached:
                  value:
                    error:
                      code: plan_limit_reached
                      message: Plan limit reached.
                      fields: {}
                slug_taken:
                  value:
                    error:
                      code: slug_taken
                      message: That inbox address is already taken. Choose a different routing_key.
                      fields: {}
                conflict:
                  value:
                    error:
                      code: conflict
                      message: Request conflicts with the current state.
                      fields: {}
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /inboxes/{id}:
    get:
      tags: [inboxes]
      summary: Get an inbox
      description: |
        Returns one inbox plus two detail fields: `message_count_month` (messages
        received this calendar month) and `webhook_configured` (whether a
        webhook row exists for the inbox).
      operationId: getInbox
      parameters:
        - $ref: "#/components/parameters/inboxId"
      responses:
        "200":
          description: The inbox.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/InboxDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [inboxes]
      summary: Delete an inbox
      description: |
        Atomically disables and soft-deletes the inbox. Returns `204`. A second
        delete returns the same `404` as an unknown or foreign ID.
      operationId: deleteInbox
      parameters:
        - $ref: "#/components/parameters/inboxId"
      responses:
        "204":
          description: Inbox deleted (no content).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /inboxes/{id}/messages:
    get:
      tags: [messages]
      summary: List messages in an inbox
      description: |
        Returns message summaries for one inbox, newest first, with cursor
        pagination. The optional `has_otp`, `subject_contains` and `from`
        filters are ANDed together; invalid values are ignored gracefully
        (never a 500). The pagination cursor is bound to the exact filter set,
        so a cursor minted under one filter cannot be replayed under another.
      operationId: listInboxMessages
      parameters:
        - $ref: "#/components/parameters/inboxId"
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/cursor"
        - name: has_otp
          in: query
          required: false
          description: |
            When `true`, keep only messages with a non-empty OTP
            (`otp IS NOT NULL` and `otp != ''`).
          schema:
            type: boolean
        - name: subject_contains
          in: query
          required: false
          description: |
            Case-insensitive substring match on the subject. Capped at 200
            characters; blank values are ignored.
          schema:
            type: string
            maxLength: 200
        - name: from
          in: query
          required: false
          description: Case-insensitive substring match on the From header.
          schema:
            type: string
        - name: read
          in: query
          required: false
          description: |
            `true` (only read messages) | `false` (only unread messages) |
            `all` (no filter, default). Absent or an unrecognized value
            behaves like `all`. The pagination cursor is bound to the exact
            `read` value used to mint it — a cursor minted under one value
            cannot be replayed under another (`422 validation_error`).
          schema:
            type: string
            enum: ["true", "false", "all"]
        - name: q
          in: query
          required: false
          description: |
            Free-text search: case-insensitive substring match on EITHER the
            subject OR the From header (`OR`-composed with each other,
            `AND`-composed with every other filter). Capped at 200
            characters; blank values are ignored. The pagination cursor is
            bound to the exact `q` value used to mint it — a cursor minted
            under one value cannot be replayed under another
            (`422 validation_error`).
          schema:
            type: string
            maxLength: 200
      responses:
        "200":
          description: A page of message summaries.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageSummaryListResponse"
        "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"

  /inboxes/{id}/messages/latest:
    get:
      tags: [messages]
      summary: Get the latest message in an inbox
      description: Returns the single newest message (full representation) or `404` when the inbox has no messages.
      operationId: latestInboxMessage
      parameters:
        - $ref: "#/components/parameters/inboxId"
      responses:
        "200":
          description: The latest message.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /inboxes/{id}/messages/wait:
    get:
      tags: [messages]
      summary: Wait for an OTP or magic link
      description: |
        Blocks until a matching parsed message arrives, or returns empty when the
        window elapses. This is the "one call, wait for the code" primitive.

        - `timeout` — seconds to block (default 20, clamped to `[1, 25]`).
        - `since` — only messages received strictly after this point match.
          Accepted forms: an ISO-8601 datetime, a message `public_id` (resolved
          to that owned message's `received_at`), or a unix timestamp (seconds;
          values ≥ 1e12 are treated as milliseconds). Default: the request start
          time. Pass `since=0` (the epoch) to mean "any existing unseen code".
        - `require` — `otp` (default) | `link` | `any`.
        - `min_confidence` — float, default 0.0; an OTP must have
          `otp_confidence >= min_confidence`. Clamped to `[0, 1]`. Ignored for
          `require=link`.

        A single wait occupies one gthread worker for up to `timeout` seconds.
        Concurrency is bounded by a per-account cap and a global cap, surfaced
        as the two distinct `429` codes below.
      operationId: waitForMessage
      parameters:
        - $ref: "#/components/parameters/inboxId"
        - name: timeout
          in: query
          required: false
          description: Seconds to block. Default 20. Clamped to [1, 25].
          schema:
            type: number
            default: 20
            minimum: 1
            maximum: 25
        - name: since
          in: query
          required: false
          description: |
            ISO-8601 datetime, a message `public_id`, or a unix timestamp.
            Only messages received strictly after this point match.
          schema:
            type: string
        - name: require
          in: query
          required: false
          description: What counts as a match.
          schema:
            type: string
            enum: [otp, link, any]
            default: otp
        - name: min_confidence
          in: query
          required: false
          description: Minimum `otp_confidence` for an OTP match. Default 0.0.
          schema:
            type: number
            default: 0.0
      responses:
        "200":
          description: A matching parsed message arrived.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Message"
        "204":
          description: Waited, nothing matched yet; call again. Success, not an error.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          description: |
            Concurrent-wait cap exhausted. Two distinct codes:
            `too_many_wait_requests` (per-account cap) and `wait_capacity`
            (global cap). Release is automatic when a wait returns, times out,
            or the client disconnects; retry shortly.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                too_many_wait_requests:
                  value:
                    error:
                      code: too_many_wait_requests
                      message: Too many concurrent wait requests.
                      fields: {}
                wait_capacity:
                  value:
                    error:
                      code: wait_capacity
                      message: Too many concurrent wait requests across all accounts.
                      fields: {}

  /messages/{id}:
    get:
      tags: [messages]
      summary: Get a message
      description: Returns a single message in its full representation.
      operationId: getMessage
      parameters:
        - $ref: "#/components/parameters/messageId"
      responses:
        "200":
          description: The message.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      tags: [messages]
      summary: Mark a message read or unread
      description: |
        Sets the message's `read` state. Setting `read: true` records
        `read_at` (the first time only — a repeat `read: true` is a no-op, not
        an error). Setting `read: false` clears `read_at`. Idempotent either
        way. Returns the full updated message.
      operationId: patchMessage
      parameters:
        - $ref: "#/components/parameters/messageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [read]
              properties:
                read:
                  type: boolean
      responses:
        "200":
          description: The updated message.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Message"
        "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"

  /messages/batch:
    post:
      tags: [messages]
      summary: Batch operate on messages by id
      description: |
        Applies one `action` (`mark_read` | `delete` | `fetch`) across up to
        100 caller-owned message ids in one call. Tenant-scoped: an id that
        does not exist and an id owned by another account are indistinguishable
        (`status: not_found` for both — this endpoint is never an existence
        oracle). Requires a paid plan (`402 plan_upgrade_required` on Free).
        `mark_read` and `delete` are idempotent — a repeat call on
        already-processed ids returns the same shape without re-mutating.
      operationId: batchMessages
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageBatchRequest"
      responses:
        "200":
          description: Per-id results, in the same order as the input `ids`.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageBatchResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Free-plan account (batch operations require a paid plan).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: plan_upgrade_required
                  message: This feature requires a paid plan. Upgrade to use it.
                  fields: {}
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /inboxes/{id}/webhook/test:
    post:
      tags: [inboxes]
      summary: Send one synchronous signed test webhook delivery
      description: |
        Sends exactly one synchronous, signed test event through the same
        SSRF-pinned transport and signing primitives real deliveries use.
        Creates NO `WebhookDelivery` row (no retry, no drain interaction), so
        it never appears in delivery history. Allowed even when the webhook
        is disabled (verify a receiver before enabling it). Counts toward the
        monthly `webhook_attempts` usage, exactly like a real delivery attempt
        — including failed/rejected attempts, not just delivered ones.
      operationId: testWebhook
      parameters:
        - $ref: "#/components/parameters/inboxId"
      responses:
        "200":
          description: The test send was attempted (delivered is true whenever the transport returned any HTTP response, including non-2xx).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [delivered, response_status, signature_version, event_id]
                    properties:
                      delivered:
                        type: boolean
                      response_status:
                        type: integer
                        description: The receiver's HTTP status code.
                      signature_version:
                        type: string
                      event_id:
                        type: string
                        description: Synthetic event id for this test send, prefixed `evt_test_`.
        "400":
          description: The webhook URL was rejected by SSRF validation before any network call was made.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: webhook_ssrf_rejected
                  message: The webhook URL was rejected by SSRF validation.
                  fields:
                    url: ["blocked_ip"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Free-plan account (webhooks require a paid plan).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: plan_upgrade_required
                  message: This feature requires a paid plan. Upgrade to use it.
                  fields: {}
        "403":
          description: |
            Either account-level forbidden, or the plan does not include
            webhooks (`webhook_entitlement_required`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: webhook_entitlement_required
                  message: Webhooks require a paid plan.
                  fields: {}
        "404":
          description: |
            Either the inbox is not owned by the caller (`not_found`), or it
            is owned but has no webhook configured (`webhook_not_configured`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                not_found:
                  value:
                    error:
                      code: not_found
                      message: Resource not found.
                      fields: {}
                webhook_not_configured:
                  value:
                    error:
                      code: webhook_not_configured
                      message: No webhook is configured for this inbox.
                      fields: {}
        "429":
          description: |
            Per-inbox (10/hour) or per-account (30/hour) test-send rate limit
            exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: rate_limited
                  message: Rate limit exceeded.
                  fields: {}
        "502":
          description: The transport could not reach the receiver (timeout, connection refused, TLS failure).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: webhook_unreachable
                  message: The webhook receiver could not be reached.
                  fields:
                    error: ["timeout"]

  /agents/register:
    post:
      tags: [agents]
      summary: Self-register a zero-signup agent API key
      description: |
        UNauthenticated on purpose. An agent (or a dev) posts an optional
        `name` and gets back a working API key plus a ready-to-use inbox in
        one call — no email, no human confirmation. The plaintext key is
        returned ONCE and never retrievable again. IP-rate-limited AND
        globally rate-limited (a fixed hourly cap across all sources), and
        the resulting account is Free-plan, subject to a shared reduced
        wait-capacity pool and idle-account garbage collection.
      operationId: registerAgent
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 60
                  description: Optional label for the minted key.
      responses:
        "201":
          description: Agent account, API key, and first inbox created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [api_key, key_prefix, plan, message]
                    properties:
                      api_key:
                        type: string
                        description: The plaintext key. Shown once; store it securely.
                      key_prefix:
                        type: string
                      plan:
                        type: string
                        enum: [free]
                      message:
                        type: string
                      inbox:
                        $ref: "#/components/schemas/Inbox"
        "429":
          description: Per-IP or global agent-registration rate limit exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: rate_limited
                  message: Rate limit exceeded.
                  fields: {}

  /verify:
    post:
      tags: [verify]
      summary: Email address verification (safe or full SMTP probe)
      description: |
        `mode: "safe"` (default): syntax + DNS (MX, falling back to A/AAAA) +
        disposable/role heuristics. Never opens an SMTP connection. Rate-limited
        per key AND per account (an account holding multiple keys cannot
        multiply its effective ceiling), plus a small global in-flight
        concurrency cap so slow DNS lookups from unbounded free keys cannot
        tie up every worker.

        `mode: "full"` (Business plan, behind the `ENABLE_SMTP_VERIFY` feature
        flag — `403 smtp_verify_disabled` when off, `403
        verify_full_entitlement_required` on an ineligible plan): additionally
        opens one real SMTP session to the target domain's MX and issues a
        RCPT probe (never DATA) to check whether the specific mailbox exists.
        Separate, harder per-key/per-account rate limits and a separate small
        in-flight capacity pool apply on top of the safe-mode limits. SSRF-safe
        by construction: every resolved MX/A/AAAA address is validated as
        public before any connection is attempted; a domain that resolves
        exclusively to non-public addresses degrades to the safe verdict
        (`smtp.probe: "skipped"`, `reason: "mx_non_public"`) rather than
        erroring. Any probe failure (timeout, refused connection, per-domain
        cooldown, capacity exhaustion) gracefully degrades to `200` with the
        safe-mode verdict — this endpoint never 500s because a remote mail
        server behaved unexpectedly.
      operationId: verifyEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  maxLength: 320
                mode:
                  type: string
                  enum: [safe, full]
                  default: safe
                  description: |
                    "full" additionally performs a live SMTP RCPT probe
                    (Business plan + ENABLE_SMTP_VERIFY required).
      responses:
        "200":
          description: A verification verdict (never a hard existence guarantee).
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required:
                      - email
                      - deliverable
                      - syntax_valid
                      - domain
                      - has_mx
                      - is_disposable
                      - is_role
                      - reason
                      - checked
                    properties:
                      email:
                        type: string
                      deliverable:
                        type: string
                        enum: [deliverable, risky, undeliverable, unknown]
                      syntax_valid:
                        type: boolean
                      domain:
                        type: [string, "null"]
                      has_mx:
                        type: boolean
                      is_disposable:
                        type: boolean
                      is_role:
                        type: boolean
                      reason:
                        type: string
                      checked:
                        type: string
                      smtp:
                        type: object
                        description: 'Present only when mode is "full".'
                        properties:
                          probe:
                            type: string
                            enum: [completed, skipped, failed]
                          rcpt_code:
                            type: [integer, "null"]
                          catch_all:
                            type: [boolean, "null"]
                          reason:
                            type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account forbidden, full-mode SMTP verify disabled
            (smtp_verify_disabled), or the plan lacks the full-mode
            entitlement (verify_full_entitlement_required).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /verify/batch:
    post:
      tags: [verify]
      summary: Bulk email verification (up to 100 unique rows safe / 25 full)
      description: |
        Every email goes through the EXACT same verification path as
        `POST /verify` — this is a charged, capped, deadline-bounded loop
        over that path, NOT a separate/lighter verification. Bulk is NOT a
        rate-limit bypass: each row consumes the standard per-key/per-account
        `/verify` hourly budget (the SAME buckets single `/verify` draws
        from — singles and batches share one budget).

        Input: exactly one of `emails` (JSON array), `text` (a paste blob:
        newline/comma/semicolon/whitespace-separated), or a multipart `file`
        upload (`.csv`/`.txt`, max 64KB). Tokens are trimmed, quote-stripped,
        capped at 320 chars, and exact-match deduplicated (order-preserving)
        before verification — `N` (unique count) is what gets rate-charged,
        metered, and returned as `results`. Safe mode caps at 100 unique
        rows; full mode (Business + `ENABLE_SMTP_VERIFY`) caps at 25, since
        a full batch holds half the global full-probe capacity for its
        duration. `N == 0` or `N > cap` is a whole-batch `422` — a row that
        merely fails verification (bad syntax, disposable, a CSV header
        line) is never a batch-level error, only a per-row verdict.

        Unlike single `/verify` (which consumes its rate-limit charge even
        on the request that gets refused), a batch's charge is CONDITIONAL:
        if the full `N`-sized charge does not fit the remaining hourly
        budget, NOTHING is consumed and the whole batch returns `429`
        (documented divergence — batching must never let a client wedge its
        own budget by retry-looping a near-full bucket at high `N`).

        The batch runs under a wall-clock deadline
        (`VERIFY_BATCH_DEADLINE_SECONDS`, default 60s, well under the
        gunicorn/Cloudflare envelope). Rows not started before the deadline
        return `deliverable: "unknown"`, `reason: "batch_budget_exhausted"`
        — the rate charge and usage meter still count them (no refund race).

        Full mode's gates (flag, entitlement, full-mode rate buckets,
        per-row fleet cap, per-row domain cooldown) are the identical W7
        gates, checked once per batch where the check is request-scoped
        (flag/entitlement) and per-row where the check is per-target
        (fleet cap, domain cooldown) — a 403 from the once-per-batch checks
        happens before any row work.
      operationId: verifyEmailBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                emails:
                  type: array
                  items:
                    type: string
                  description: Mutually exclusive with `text`.
                text:
                  type: string
                  description: |
                    A paste blob; tokenized on newline/comma/semicolon/
                    whitespace. Mutually exclusive with `emails`.
                mode:
                  type: string
                  enum: [safe, full]
                  default: safe
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: A `.csv` or `.txt` file, max 64KB (VERIFY_BATCH_FILE_MAX_BYTES).
                mode:
                  type: string
                  enum: [safe, full]
                  default: safe
      responses:
        "200":
          description: |
            Per-row results in input order (first-occurrence position for
            deduplicated rows) plus a verdict summary. Each row is EXACTLY
            the single-verify `data` object shape (including the `smtp`
            sub-object in full mode) — no new verdict vocabulary.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [mode, results, summary]
                    properties:
                      mode:
                        type: string
                        enum: [safe, full]
                      results:
                        type: array
                        items:
                          type: object
                          description: Same shape as the `data` object returned by `POST /verify`.
                      summary:
                        type: object
                        required: [total, deliverable, risky, undeliverable, unknown]
                        properties:
                          total:
                            type: integer
                          deliverable:
                            type: integer
                          risky:
                            type: integer
                          undeliverable:
                            type: integer
                          unknown:
                            type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account forbidden, full-mode SMTP verify disabled
            (smtp_verify_disabled), or the plan lacks the full-mode
            entitlement (verify_full_entitlement_required) — checked once
            per batch, before any row work.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Structural batch problems only (never a per-row verdict):
            `validation_error` (both/neither of `emails`/`text` supplied, or
            zero unique rows), `batch_too_large` (over the mode's cap),
            `unsupported_file_type` (not `.csv`/`.txt`), `malformed_file`
            (not valid UTF-8, or contains a NUL byte), `file_too_large`
            (over 64KB, checked via Content-Length and re-checked against
            the actual parsed size).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /domains:
    get:
      tags: [domains]
      summary: List custom domains
      description: |
        Returns the caller's custom domains, newest first, with cursor
        pagination. `403 byod_disabled` when `ENABLE_BYOD` is off.
      operationId: listDomains
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/cursor"
      responses:
        "200":
          description: A page of domains.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Domain"
                  pagination:
                    $ref: "#/components/schemas/Pagination"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Account is not permitted to use the API, or BYOD is disabled (`byod_disabled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [domains]
      summary: Register a custom domain
      description: |
        Creates a `pending_dns` Domain for the caller. Returns the
        verification TXT host + token and the MX target to publish.
        `403 domain_limit_reached` when the plan's `custom_domains` cap is
        hit (free/paid/scale have a cap of 0). `409 domain_taken` when the
        name is already registered by any account, in any status.
      operationId: createDomain
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DomainWrite"
      responses:
        "201":
          description: Domain created.
          headers:
            Location:
              description: Path of the new domain (e.g. `/api/v1/domains/dom_...`).
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Domain"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account forbidden, BYOD disabled (`byod_disabled`), or the
            plan's custom-domain limit is reached (`domain_limit_reached`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The domain name is already registered (`domain_taken`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: domain_taken
                  message: That domain is already registered.
                  fields: {}
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /domains/{id}:
    get:
      tags: [domains]
      summary: Get a custom domain
      operationId: getDomain
      parameters:
        - $ref: "#/components/parameters/domainId"
      responses:
        "200":
          description: The domain.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Domain"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [domains]
      summary: Delete a custom domain
      description: |
        Releases the domain name. Refuses with `409 domain_has_inboxes` if
        inboxes are still bound to it.
      operationId: deleteDomain
      parameters:
        - $ref: "#/components/parameters/domainId"
      responses:
        "204":
          description: Domain deleted (no content).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The domain still has inboxes bound to it (`domain_has_inboxes`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /domains/{id}/verify:
    post:
      tags: [domains]
      summary: Trigger an on-demand DNS verification check
      description: |
        Checks the ownership TXT record, then MX, advancing
        `pending_dns -> verified` when both match. Idempotent on an
        already-verified domain (updates only `last_checked_at`).
        Rate-limited: at most one manual verify per domain per 30 seconds,
        and at most 20 per account per hour (`429 domain_verify_rate_limited`).
      operationId: verifyDomain
      parameters:
        - $ref: "#/components/parameters/domainId"
      responses:
        "200":
          description: The domain after the verify pass.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Domain"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          description: Manual-verify rate limit exceeded (`domain_verify_rate_limited`).
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /domains/{id}/catch_all:
    patch:
      tags: [domains]
      summary: Enable or disable catch-all for a verified custom domain
      description: |
        Business plan only. Enabling requires the domain to be `verified`
        (else `409 domain_not_verified`). `inbox_id` is optional: when
        omitted, a sentinel Inbox is auto-created and bound to the domain
        (subject to the plan's inbox cap, else `403 inbox_limit_reached`);
        when given, it must be a live inbox already bound to this domain
        (else `422 catch_all_target_invalid`, or `404
        domain_not_found_or_unverified` if it does not exist / is not
        owned by the caller). Disabling keeps the designated inbox bound
        (inert while `catch_all` is false) so re-enabling is cheap.
        Rate-limited: at most 6 toggle calls per domain per hour
        (`429 catch_all_rate_limited`).
      operationId: setDomainCatchAll
      parameters:
        - $ref: "#/components/parameters/domainId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [catch_all]
              properties:
                catch_all:
                  type: boolean
                inbox_id:
                  type: string
                  nullable: true
                  description: Optional; only meaningful when enabling.
      responses:
        "200":
          description: The domain after the catch-all toggle.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Domain"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account forbidden, BYOD disabled (`byod_disabled`), or the plan
            does not include catch-all (`catch_all_entitlement_required`),
            or the plan's inbox limit is reached (`inbox_limit_reached`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Domain is not verified (`domain_not_verified`), or the
            designated inbox is the active catch-all target and cannot be
            reused this way (`catch_all_target`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: The designated inbox_id is not a live inbox bound to this domain (`catch_all_target_invalid`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Catch-all toggle rate limit exceeded (`catch_all_rate_limited`).
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /domains/{id}/sending_setup:
    post:
      tags: [domains]
      summary: Provision a fresh per-domain DKIM keypair for outbound sending
      description: |
        W5 / Batch 2 (outbound send). Behind the `ENABLE_OUTBOUND` feature
        flag (`404` when off — this whole surface does not exist). Generates
        a fresh RSA-2048 keypair, encrypts the private key at rest, and
        returns `dkim_selector` + `dkim_public_key_txt` (the TXT record to
        publish at `<dkim_selector>._domainkey.<name>`). Requires the domain
        to already be `verified` (else `409 domain_not_verified`).
        Rate-limited: at most 6 calls per domain per hour.
      operationId: provisionDomainSendingSetup
      parameters:
        - $ref: "#/components/parameters/domainId"
      responses:
        "200":
          description: The domain with DKIM provisioning fields set (`dkim_status=pending_dns`).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Domain"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The domain must be `verified` before DKIM setup (`domain_not_verified`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Sending-setup rate limit exceeded (6/hour/domain).
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /outbound_emails:
    post:
      tags: [outbound]
      summary: Send an email from a verified, DKIM-verified custom domain
      description: |
        W5 / Batch 2 (outbound send) — THE highest reputation-risk surface.
        Behind `ENABLE_OUTBOUND` (`404` when off). Business plan only; every
        other tier (and any demo/agent account) is unconditionally refused.
        `from_address`'s domain must be owned by the caller, `verified`, and
        `dkim_status=verified` — any other cause (not found, not owned, not
        verified, DKIM not verified) collapses to the SAME
        `403 sending_domain_not_eligible`, with no distinguishing detail.
        Queued (`status=queued`) on success; delivered asynchronously by the
        drain, which re-validates every gate fresh at send time.
      operationId: createOutboundEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OutboundEmailWrite"
      responses:
        "201":
          description: The queued outbound email.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/OutboundEmail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account forbidden, not Business-tier / demo-or-agent
            (`outbound_entitlement_required`), the plan's day/hour recipient
            cap would be exceeded (`outbound_quota_exceeded`), or the
            sending-domain invariant failed (`sending_domain_not_eligible`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            Malformed field, CRLF/control characters in a user-supplied
            field, more than 10 total recipients, or one or more recipients
            are on the suppression list (`recipient_rejected`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Velocity/queue-depth guard (`outbound_rate_limited`) or the standard per-key rate limit.
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /outbound_emails/{id}:
    get:
      tags: [outbound]
      summary: Get the status of a sent/queued outbound email
      description: W5 / Batch 2 (outbound send). Behind `ENABLE_OUTBOUND` (`404` when off).
      operationId: getOutboundEmail
      parameters:
        - name: id
          in: path
          required: true
          description: The outbound email public ID.
          schema:
            type: string
      responses:
        "200":
          description: The outbound email.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/OutboundEmail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /outbound_allowlist:
    get:
      tags: [outbound]
      summary: List approved recipients for outbound send
      description: |
        W6 (approved-recipients allowlist). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only (`outbound_allowlist_entitlement_required`
        otherwise); owner-minted API key required.
      operationId: listApprovedRecipients
      responses:
        "200":
          description: Paginated list of the caller's approved recipients.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApprovedRecipient"
                  pagination:
                    $ref: "#/components/schemas/Pagination"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [outbound]
      summary: Approve a recipient address for outbound send
      description: |
        W6 (approved-recipients allowlist). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only; owner-minted API key required. The
        address is stored via the same normalization used at both
        enforcement points (enqueue + drain re-check).
      operationId: createApprovedRecipient
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ApprovedRecipientWrite"
      responses:
        "201":
          description: The approved recipient.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/ApprovedRecipient"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The address is already approved for this account (`approved_recipient_exists`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Malformed email, or the allowlist entry cap has been reached (`outbound_allowlist_limit_reached`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many allowlist changes (`outbound_allowlist_rate_limited`).
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /outbound_allowlist/{id}:
    delete:
      tags: [outbound]
      summary: Revoke an approved recipient
      description: |
        W6 (approved-recipients allowlist). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only; owner-minted API key required.
        Wrong-tenant and nonexistent ids are byte-identical `404`.
      operationId: deleteApprovedRecipient
      parameters:
        - name: id
          in: path
          required: true
          description: The approved-recipient public ID.
          schema:
            type: string
      responses:
        "204":
          description: Recipient revoked.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /outbound_allowlist/mode:
    post:
      tags: [outbound]
      summary: Enable/disable approved-recipients allowlist enforcement
      description: |
        W6 (approved-recipients allowlist). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only; owner-minted API key required. Flips
        `Account.outbound_allowlist_enabled` — the enforcement flag read by
        both the enqueue gate and the drain re-check, never the plan tier.
      operationId: setOutboundAllowlistMode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OutboundAllowlistModeWrite"
      responses:
        "200":
          description: The new enforcement state.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [enabled]
                    properties:
                      enabled:
                        type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /inboxes/{id}/messages/{message_id}/reply:
    post:
      tags: [outbound]
      summary: Reply to an inbound message
      description: |
        W8 (inbox reply/compose/forward). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only; owner-minted API key required.
        Thin reuse layer over the W5 outbound pipeline — the reply target,
        subject, and threading headers are all re-derived/re-validated
        server-side and end in the same `enqueue_outbound_email` gate the
        `POST /outbound_emails` view uses. `from_address` is always the
        replying inbox's own address (never client-supplied) and must
        belong to a domain the caller owns, `verified`, and
        `dkim_status=verified` — shared inboxes cannot reply.
      operationId: replyToMessage
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: message_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InboxReplyWrite"
      responses:
        "201":
          description: Reply queued.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/OutboundEmail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            `reply_target_invalid` (no usable Reply-To/From/envelope
            address could be derived), malformed field, CRLF/control
            characters, more than 10 total recipients, or one or more
            recipients are on the suppression/allowlist
            (`recipient_rejected`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Velocity/queue-depth guard (`outbound_rate_limited`) or the standard per-key rate limit.
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /inboxes/{id}/messages/{message_id}/forward:
    post:
      tags: [outbound]
      summary: Forward an inbound message
      description: |
        W8 (inbox reply/compose/forward). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only; owner-minted API key required.
        Quotes the original message as text in the body (no attachments)
        and ends in the same `enqueue_outbound_email` gate as `POST
        /outbound_emails`. `from_address` is always the forwarding
        inbox's own address (never client-supplied).
      operationId: forwardMessage
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: message_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InboxForwardWrite"
      responses:
        "201":
          description: Forward queued.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/OutboundEmail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            Malformed field, CRLF/control characters, more than 10 total
            recipients, or one or more recipients are on the
            suppression/allowlist (`recipient_rejected`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Velocity/queue-depth guard (`outbound_rate_limited`) or the standard per-key rate limit.
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /inboxes/{id}/compose:
    post:
      tags: [outbound]
      summary: Send a new email from an inbox
      description: |
        W8 (inbox reply/compose/forward). Behind `ENABLE_OUTBOUND` (`404`
        when off). Business plan only; owner-minted API key required.
        Pure passthrough over the W5 outbound pipeline with
        `from_address` pinned server-side to the inbox's own address —
        ends in the same `enqueue_outbound_email` gate as `POST
        /outbound_emails`.
      operationId: composeFromInbox
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InboxComposeWrite"
      responses:
        "201":
          description: Email queued.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/OutboundEmail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            Malformed field, CRLF/control characters, more than 10 total
            recipients, or one or more recipients are on the
            suppression/allowlist (`recipient_rejected`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Velocity/queue-depth guard (`outbound_rate_limited`) or the standard per-key rate limit.
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /inboxes/{id}/stream:
    get:
      tags: [messages]
      summary: Server-Sent Events push of new messages
      description: |
        SSE alternative to long-poll `wait`: emits a `message` event per
        newly-ingested matching message, a periodic `: heartbeat` comment,
        and a final `bye` event before closing (clients reconnect with a
        fresh `since` cursor via the advertised `retry:`). Consumes the same
        per-account and global wait-capacity slots as `messages/wait`
        (routed through a smaller reserved sub-pool for Free/agent-plan
        accounts so free traffic can never starve paid waits).
      operationId: streamMessages
      parameters:
        - $ref: "#/components/parameters/inboxId"
        - name: require
          in: query
          required: false
          schema:
            type: string
            enum: [otp, link, any]
            default: any
        - name: min_confidence
          in: query
          required: false
          schema:
            type: number
            default: 0.0
        - name: since
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: text/event-stream of `message`/heartbeat/`bye` events.
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          description: |
            Concurrent-wait cap exhausted (`too_many_wait_requests` per
            account, or `wait_capacity` for the global/free-plan sub-pool).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /public/emails/{email}/messages:
    get:
      tags: [messages]
      summary: Anonymous read of a public inbox's recent messages
      description: |
        Batch 1a W2. NO authentication. Requires the inbox owner to have
        opted in with `POST /inboxes {"public": true}` — every inbox is
        private by default (`is_public` defaults `false`), and this is the
        one and only door to reading it anonymously. Custom-domain (BYOD)
        inboxes can never be resolved here even if `is_public` were somehow
        forced true directly in the database: the read path also requires
        the address to parse as a bare shared-domain (`MAIL_DOMAIN`) address
        with no bound `Domain`.

        Every miss — unknown address, private inbox (the default), disabled,
        soft-deleted, malformed address, or a custom-domain address — returns
        the SAME `404 not_found` body, so this endpoint can never be used to
        enumerate which addresses exist.

        The response never includes `otp_candidates`, `metadata`, `text`, or
        `html` — only the same summary fields (from/subject/otp/etc) an
        owner sees via `GET /inboxes/{id}/messages`, minus those four.
      operationId: publicInboxMessages
      security: []
      parameters:
        - name: email
          in: path
          required: true
          description: |
            A shared-domain email address, e.g. `someone@in.inboxpipe.net`.
            Any other shape (missing/extra `@`, non-shared domain, invalid
            local-part) is a `404`, never a `422` — the address shape itself
            must not be a distinguishable signal.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Page size, 1 to 20. Default 20.
          schema:
            type: integer
            minimum: 1
            maximum: 20
            default: 20
      responses:
        "200":
          description: Up to `limit` newest messages, newest first.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/MessageSummary"
        "404":
          description: |
            Unknown, private (default), disabled, deleted, custom-domain, or
            malformed address — all identical, no enumeration oracle.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: not_found
                  message: Resource not found.
                  fields: {}
        "429":
          description: Per-IP rate limit exceeded (30/minute).
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "API key"
      description: |
        mailsocket API key, prefixed `ms_live_`. Send as
        `Authorization: Bearer ms_live_...`.

  parameters:
    inboxId:
      name: id
      in: path
      required: true
      description: The inbox public ID (e.g. `inbox_...`).
      schema:
        type: string
    messageId:
      name: id
      in: path
      required: true
      description: The message public ID (e.g. `msg_...`).
      schema:
        type: string
    domainId:
      name: id
      in: path
      required: true
      description: The domain public ID (e.g. `dom_...`).
      schema:
        type: string
    limit:
      name: limit
      in: query
      required: false
      description: Page size, 1 to 100. Default 25.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    cursor:
      name: cursor
      in: query
      required: false
      description: Opaque, signed, owner- and filter-bound pagination cursor.
      schema:
        type: string

  headers:
    X-RateLimit-Limit:
      description: The rate-limit ceiling for the matched bucket.
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix timestamp (seconds) when the window resets.
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying (present on `429`).
      schema:
        type: integer

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      headers:
        WWW-Authenticate:
          description: Always `Bearer`.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: authentication_required
              message: Authentication required.
              fields: {}
    Forbidden:
      description: Account is not permitted to use the API.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: account_forbidden
              message: Account is not permitted to use the API.
              fields: {}
    NotFound:
      description: Resource not found (unknown or not owned).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: not_found
              message: Resource not found.
              fields: {}
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          $ref: "#/components/headers/Retry-After"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: rate_limited
              message: Rate limit exceeded.
              fields: {}
    ValidationError:
      description: Request validation failed (malformed body or invalid parameter).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: validation_error
              message: Request validation failed.
              fields: {}
    UnsupportedMediaType:
      description: Request media type is not supported.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: unsupported_media_type
              message: Request media type is not supported.
              fields: {}

  schemas:
    Inbox:
      type: object
      required: [id, label, address, is_enabled, created_at, updated_at, metadata]
      properties:
        id:
          type: string
          description: The inbox public ID.
        label:
          type: string
        address:
          type: string
          description: The full inbound email address.
        is_enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        metadata:
          type: object
          description: |
            Batch 1a W3. Owner correlation metadata, `{}` when unset. Never
            appears on either anonymous surface (instant `.md`, public
            messages).

    InboxDetail:
      allOf:
        - $ref: "#/components/schemas/Inbox"
        - type: object
          required: [message_count_month, webhook_configured]
          properties:
            message_count_month:
              type: integer
              description: Messages received this calendar month.
            webhook_configured:
              type: boolean
              description: Whether a webhook row exists for this inbox.

    InboxWrite:
      type: object
      properties:
        label:
          type: string
          maxLength: 120
          description: Optional inbox label.
        routing_key:
          type: string
          minLength: 3
          maxLength: 64
          pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{2,63}$"
          description: |
            Optional custom slug (the local-part of the inbox address). Input
            is normalized to lowercase BEFORE validation, so callers may pass
            either case (e.g. `MyCustomBox` is accepted and stored as
            `mycustombox`); the pattern above matches the raw (pre-normalized)
            input character set. Must start with a letter or digit and contain
            only `A-Za-z`, `0-9`, `_`, `-` after that (3-64 chars total). When
            omitted, a random routing key is minted. Returns `409 slug_taken`
            if another account already owns it (checked case-insensitively,
            after lowercasing), or `422 validation_error` if it fails the
            pattern/length constraints or is a reserved key. The reserved-key
            blocklist applies ONLY when `domain` is omitted (shared domain);
            it is not enforced on a custom domain (the owner controls their
            whole namespace there).
        domain:
          type: string
          maxLength: 32
          description: |
            Phase 3 Stage B (BYOD). Optional public id (`dom_...`) of a
            VERIFIED custom Domain owned by the SAME account to bind this
            inbox to. Omitted (or blank) creates a shared
            `in.inboxpipe.net` inbox, exactly as before this field existed.
            Returns `422 domain_not_found_or_unverified` if the domain does
            not exist, is not owned by the caller, or is not `verified`;
            `403 byod_disabled` if the BYOD feature flag is off.
        public:
          type: boolean
          default: false
          description: |
            Batch 1a W2. Opt-in flag making the inbox's recent messages
            readable with NO auth via `GET /public/emails/{email}/messages`.
            Defaults `false` — every inbox is private unless explicitly
            opted in. Shared-domain inboxes ONLY: `true` combined with a
            non-blank `domain` returns
            `422 public_not_allowed_on_custom_domain`. Anyone holding the
            address can read up to 20 recent messages (never the full
            body/metadata) once set — flip it back off to revoke access.
        metadata:
          type: object
          description: |
            Batch 1a W3. Optional owner correlation metadata (e.g.
            `{"run": "ci-123"}`), echoed back on every OWNER-facing read
            surface (inbox get/list, message poll/latest/wait/batch, webhook
            payload). NEVER exposed on either anonymous surface (the instant
            `.md` endpoints or `GET /public/emails/{email}/messages`).
            Must be a JSON object (`422 metadata_must_be_object` otherwise),
            at most 4096 bytes of compact UTF-8 JSON and at most 50
            top-level keys (`422 metadata_too_large` otherwise). Omitted
            defaults to `{}`.

    Domain:
      type: object
      required:
        - id
        - name
        - status
        - verification_txt_host
        - verification_token
        - mx_target
        - last_checked_at
        - last_error
        - verified_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: The domain public ID.
        name:
          type: string
          description: The FQDN, stored lowercase. Immutable after create.
        status:
          type: string
          enum: [pending_dns, verified, failed, disabled]
        verification_txt_host:
          type: string
          description: The TXT record host to publish (`_mailsocket-verify.<name>`).
        verification_token:
          type: string
          description: The TXT record value to publish at `verification_txt_host`.
        mx_target:
          type: string
          description: The MX target that must be present once DNS is live (`mx.mailsocket.app`).
        last_checked_at:
          type: string
          format: date-time
          nullable: true
        last_error:
          type: string
        verified_at:
          type: string
          format: date-time
          nullable: true
        catch_all:
          type: boolean
          description: |
            W4 / Batch 1b Stage C. True when unmatched localparts on this
            (verified) domain route to catch_all_inbox_id. Toggled via
            `PATCH /domains/{id}/catch_all`.
        catch_all_inbox_id:
          type: string
          nullable: true
          description: The sentinel inbox's public ID, or null when catch-all has never been configured.
        dkim_status:
          type: string
          enum: [none, pending_dns, verified, failed]
          description: |
            W5 / Batch 2. Set by `POST /domains/{id}/sending_setup`
            (`pending_dns`), then advanced to `verified` by the verify/
            recheck DNS pass once the DKIM TXT record is published, or
            demoted to `failed` after 3 consecutive verify failures.
        dkim_selector:
          type: string
          description: DKIM selector; the TXT record is published at `<dkim_selector>._domainkey.<name>`.
        dkim_public_key_txt:
          type: string
          description: The DKIM public-key TXT record value to publish. Empty until `sending_setup` runs.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DomainWrite:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 253
          description: |
            The fully-qualified domain name to register (e.g.
            `otp.example.com`). Lower-cased before validation. Rejected
            (`422 validation_error`) if it is not a valid FQDN, or if it is
            a subdomain of `inboxpipe.net`/`mailsocket.app`. Rejected with
            `409 domain_taken` if already registered by any account.

    MessageStatus:
      type: string
      enum: [received, parsing, parsed, failed]

    MessageSummary:
      type: object
      required:
        - id
        - inbox_id
        - from
        - to
        - cc
        - subject
        - received_at
        - status
        - has_html
        - metadata
      properties:
        id:
          type: string
        inbox_id:
          type: string
        from:
          type: string
          description: The From header.
        to:
          type: array
          items:
            type: string
        cc:
          type: array
          items:
            type: string
        subject:
          type: string
        received_at:
          type: string
          format: date-time
        status:
          $ref: "#/components/schemas/MessageStatus"
        otp:
          type: [string, "null"]
        otp_confidence:
          type: [number, "null"]
        magic_link:
          type: [string, "null"]
        links:
          type: [array, "null"]
          items:
            type: string
        detected_otp:
          type: [string, "null"]
        otp_context:
          type: [string, "null"]
          description: |
            A short (≤160 character) plain-text snippet around the extracted
            `otp`, e.g. "Your verification code is 123456. It expires in 10
            minutes." HTML is stripped, whitespace collapsed, and the window is
            bounded so the full body is never exposed. Null when there is no
            OTP or its location cannot be found. Best-effort, additive.
        otp_candidates:
          type: [array, "null"]
          description: |
            Ranked plausible codes beyond the primary `otp` (the also-rans an
            agent can use to disambiguate), scored with the same logic as the
            primary extraction. Each item is `{code, confidence}`. Null/empty
            when there is only one plausible code or none.
          items:
            type: object
            required: [code, confidence]
            properties:
              code:
                type: string
              confidence:
                type: number
        provider_hint:
          type: [string, "null"]
          description: |
            Best-effort label of the sending service derived from the From
            header domain (e.g. "github", "google", "stripe") via a small
            static map. Null when the domain is unknown. Convenience only — not
            authoritative, no network lookup.
        read:
          type: boolean
          description: Whether the message has been marked read (`read_at IS NOT NULL`).
        metadata:
          type: object
          description: |
            Batch 1a W3. The owning inbox's `metadata` (`{}` when unset),
            echoed on every owner-facing read. NEVER present on the
            anonymous public-messages payload (`PublicMessageSerializer`
            removes it).

    Message:
      type: object
      description: |
        Full message representation: `MessageSummary` minus `has_html`, plus
        `envelope_from`, `envelope_recipient`, `text` and `html`.
      required:
        - id
        - inbox_id
        - from
        - to
        - cc
        - subject
        - received_at
        - status
        - envelope_from
        - envelope_recipient
        - text
        - html
        - metadata
      properties:
        id:
          type: string
        inbox_id:
          type: string
        from:
          type: string
          description: The From header.
        to:
          type: array
          items:
            type: string
        cc:
          type: array
          items:
            type: string
        subject:
          type: string
        received_at:
          type: string
          format: date-time
        status:
          $ref: "#/components/schemas/MessageStatus"
        otp:
          type: [string, "null"]
        otp_confidence:
          type: [number, "null"]
        magic_link:
          type: [string, "null"]
        links:
          type: [array, "null"]
          items:
            type: string
        detected_otp:
          type: [string, "null"]
        otp_context:
          type: [string, "null"]
          description: A short (≤160 character) plain-text snippet around `otp`; null when no OTP or not locatable.
        otp_candidates:
          type: [array, "null"]
          description: Ranked plausible codes beyond the primary `otp`; null/empty when only one or none.
          items:
            type: object
            required: [code, confidence]
            properties:
              code:
                type: string
              confidence:
                type: number
        provider_hint:
          type: [string, "null"]
          description: Best-effort sending-service label from the From domain; null when unknown.
        envelope_from:
          type: string
        envelope_recipient:
          type: string
        text:
          type: string
          description: The plain-text body.
        html:
          type: string
          description: The sanitized HTML body.
        read:
          type: boolean
          description: Whether the message has been marked read (`read_at IS NOT NULL`).
        metadata:
          type: object
          description: |
            Batch 1a W3. The owning inbox's `metadata` (`{}` when unset).
            Present in poll/latest/wait/batch fetch AND the webhook delivery
            payload (which is this exact schema).

    Pagination:
      type: object
      required: [next_cursor, has_more]
      properties:
        next_cursor:
          type: [string, "null"]
          description: Opaque cursor for the next page, or null when there is none.
        has_more:
          type: boolean

    InboxListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Inbox"
        pagination:
          $ref: "#/components/schemas/Pagination"

    MessageSummaryListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/MessageSummary"
        pagination:
          $ref: "#/components/schemas/Pagination"

    OutboundEmailWrite:
      type: object
      required: [from_address, to]
      properties:
        from_address:
          type: string
          maxLength: 320
          description: |
            Must be `local@domain` where `domain` is a Domain the caller
            owns, `verified`, and `dkim_status=verified` — else `403
            sending_domain_not_eligible` (uniform for every cause).
        to:
          type: array
          items:
            type: string
            maxLength: 320
          minItems: 1
          maxItems: 10
        cc:
          type: array
          items:
            type: string
            maxLength: 320
          maxItems: 10
        bcc:
          type: array
          items:
            type: string
            maxLength: 320
          maxItems: 10
        reply_to:
          type: string
          maxLength: 320
        subject:
          type: string
          maxLength: 512
        body_text:
          type: string
        body_html:
          type: string

    InboxReplyWrite:
      type: object
      properties:
        body_text:
          type: string
        body_html:
          type: string
        cc:
          type: array
          items:
            type: string
            maxLength: 320
          maxItems: 10
        reply_all:
          type: boolean
          default: false
          description: |
            When true, `to` also includes the original message's
            To/Cc addr-specs (deduped, case-insensitive), minus the
            replying inbox itself and the primary reply target.

    InboxForwardWrite:
      type: object
      required: [to]
      properties:
        to:
          type: array
          items:
            type: string
            maxLength: 320
          minItems: 1
          maxItems: 10
        body_text:
          type: string
        body_html:
          type: string

    InboxComposeWrite:
      type: object
      required: [to, subject]
      properties:
        to:
          type: array
          items:
            type: string
            maxLength: 320
          minItems: 1
          maxItems: 10
        cc:
          type: array
          items:
            type: string
            maxLength: 320
          maxItems: 10
        subject:
          type: string
          maxLength: 512
        body_text:
          type: string
        body_html:
          type: string

    OutboundEmail:
      type: object
      required:
        - id
        - status
        - from_address
        - to
        - cc
        - bcc
        - subject
        - recipient_count
        - error_code
        - created_at
        - queued_at
        - sent_at
        - bounced_at
      properties:
        id:
          type: string
          description: The outbound email public ID.
        status:
          type: string
          enum: [queued, sending, sent, failed, rejected, bounced, cancelled]
        from_address:
          type: string
        to:
          type: array
          items:
            type: string
        cc:
          type: array
          items:
            type: string
        bcc:
          type: array
          items:
            type: string
        subject:
          type: string
        recipient_count:
          type: integer
        error_code:
          type: string
          description: The failure/rejection reason code; empty string when not applicable.
        created_at:
          type: string
          format: date-time
        queued_at:
          type: string
          format: date-time
          nullable: true
        sent_at:
          type: string
          format: date-time
          nullable: true
        bounced_at:
          type: string
          format: date-time
          nullable: true

    ApprovedRecipientWrite:
      type: object
      required: [email]
      properties:
        email:
          type: string
          description: The recipient address to approve for outbound send.

    ApprovedRecipient:
      type: object
      required:
        - id
        - email
        - created_at
      properties:
        id:
          type: string
          description: The approved-recipient public ID.
        email:
          type: string
          description: Normalized (lowercased local+domain) approved address.
        created_at:
          type: string
          format: date-time

    OutboundAllowlistModeWrite:
      type: object
      required: [enabled]
      properties:
        enabled:
          type: boolean

    MessageBatchRequest:
      type: object
      required: [action, ids]
      properties:
        action:
          type: string
          enum: [mark_read, delete, fetch]
        ids:
          type: array
          items:
            type: string
          minItems: 1
          maxItems: 100
          description: Message public ids. Duplicates are de-duplicated (order-preserving) before processing.

    MessageBatchResultItem:
      type: object
      required: [id, status]
      properties:
        id:
          type: string
        status:
          type: string
          enum: [ok, deleted, not_found]
        message:
          $ref: "#/components/schemas/Message"
          description: Present only for `action=fetch` results with `status=ok`.

    MessageBatchResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [results]
          properties:
            results:
              type: array
              description: Same order as the input `ids`.
              items:
                $ref: "#/components/schemas/MessageBatchResultItem"

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, fields]
          properties:
            code:
              type: string
              enum:
                - authentication_required
                - account_forbidden
                - not_found
                - method_not_allowed
                - not_acceptable
                - conflict
                - plan_limit_reached
                - slug_taken
                - unsupported_media_type
                - validation_error
                - rate_limited
                - too_many_wait_requests
                - wait_capacity
                - server_error
                - plan_upgrade_required
                - webhook_entitlement_required
                - webhook_not_configured
                - webhook_ssrf_rejected
                - webhook_unreachable
                - byod_disabled
                - domain_entitlement_required
                - domain_limit_reached
                - domain_taken
                - domain_has_inboxes
                - domain_verify_rate_limited
                - domain_not_found_or_unverified
                - public_not_allowed_on_custom_domain
                - metadata_must_be_object
                - metadata_too_large
                - catch_all_entitlement_required
                - domain_not_verified
                - catch_all_target
                - catch_all_target_invalid
                - inbox_limit_reached
                - catch_all_rate_limited
                - outbound_disabled
                - outbound_entitlement_required
                - sending_domain_not_eligible
                - recipient_rejected
                - outbound_quota_exceeded
                - outbound_rate_limited
                - recipient_not_approved
                - outbound_allowlist_entitlement_required
                - approved_recipient_exists
                - outbound_allowlist_limit_reached
                - outbound_allowlist_rate_limited
                - smtp_verify_disabled
                - verify_full_entitlement_required
                - reply_target_invalid
                - batch_too_large
                - file_too_large
                - unsupported_file_type
                - malformed_file
            message:
              type: string
            fields:
              type: object
              description: Per-field validation details (empty object when absent).
              additionalProperties:
                type: array
                items:
                  type: string
