openapi: 3.1.0
info:
  title: Sendara API
  version: "1.0.0"
  description: |
    Sendara is a transactional email platform. It provides authenticated domain
    sending, templates, message timelines, delivery webhooks, and inbound email
    through one API, one key, and one account.

    Authenticate every request with a Bearer API key (`Authorization: Bearer sk_live_...`).
    Keys are scoped: `send` keys can send, `read` keys can read, `admin` keys can
    manage keys and domains. Test-mode keys (`sk_test_...`) simulate delivery
    without sending real messages and are exempt from billing.
  contact:
    name: Sendara
    url: https://sendara.dev
servers:
  - url: https://api.sendara.dev
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Send
    description: Send email one at a time, in batches, or validate an address.
  - name: Messages
    description: Read sent messages and their event timeline.
  - name: Mail
    description: Threaded inbox APIs for Sendara Mail (feature-flagged by MAIL_ENABLED).
  - name: Usage
    description: Account usage and cost.
  - name: API Keys
    description: Create and manage API keys (admin scope).
  - name: Domains
    description: Add and verify sending domains (admin scope).
  - name: Suppressions
    description: Manage suppressed/unsubscribed recipients.
  - name: Billing
    description: Subscription plan, checkout, and customer portal.
  - name: Templates
    description: Reusable mustache-style content templates with typed variables.
  - name: Webhooks
    description: |
      Subscribe to delivery events and receive signed event callbacks.

      Each callback is an HTTP POST to your `endpoint_url` carrying a
      `WebhookEventPayload` body and these headers:

      - `Sendara-Event-Id`: unique event id. Stable across retries. Use it to dedupe.
      - `Sendara-Event-Type`: the canonical event type.
      - `Sendara-Timestamp`: Unix seconds when the signature was computed.
      - `Sendara-Signature`: lowercase hex `HMAC-SHA256(signing_secret, "<Sendara-Timestamp>.<rawBody>")`.

      To verify: recompute the HMAC over `"<Sendara-Timestamp>.<rawBody>"` using your
      subscription's `signing_secret` and compare in constant time against
      `Sendara-Signature`. During a secret rotation, temporarily verify against
      both the old value you stored and the newly returned secret. Failed deliveries are
      retried with exponential backoff (~30s base, ×2, ±25% jitter) for up to 24h.
  - name: Test Recipients
    description: Register and verify your own addresses for free real-email test sends.
  - name: Uploads
    description: Upload images for use in email content.
  - name: Spend Caps
    description: Cap spend per account or per key (admin scope).
  - name: Account
    description: Account-level status, including per-channel sending readiness.
  - name: Inbound
    description: |
      Receive email on your domain (feature-flagged by INBOUND_ENABLED). Read
      received messages, download raw MIME and attachments, and manage forwarding
      routes (signed webhook or email forward).

paths:
  /v1/send:
    post:
      tags: [Send]
      summary: Send a message
      operationId: send
      description: |
        Send a single email. Email is the only generally available send channel
        today. The raw API requires an `idempotency_key`; official SDKs generate
        one for you when omitted. Retrying the same key returns the original
        result instead of sending twice.

        When sending from your own verified domain, `metadata.from_email` is
        required and sets the sender shown to the recipient. Omitting it returns
        `422 from_required`. An unverified domain returns `422 from_not_verified`.
        Sandbox accounts omit `from_email` and send from Sendara's shared sender.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
            examples:
              email:
                summary: Transactional email (bare from_email)
                value:
                  channel: email
                  idempotency_key: "evt_welcome_8f3a"
                  message_type: transactional
                  destination: { email: "user@example.com" }
                  payload:
                    subject: "Welcome to Acme"
                    body_html: "<h1>Welcome 🎉</h1>"
                  metadata: { from_email: "support@foliodb.space" }
              emailWithDisplayName:
                summary: Transactional email (display-name from_email)
                value:
                  channel: email
                  idempotency_key: "evt_welcome_9c2b"
                  message_type: transactional
                  destination: { email: "user@example.com" }
                  payload:
                    subject: "Welcome to Folio DB"
                    body_html: "<h1>Welcome 🎉</h1>"
                  metadata: { from_email: "Folio DB <support@foliodb.space>" }
      responses:
        "201":
          description: Message accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: |
            The send cannot be charged: the account or key has exhausted its
            monthly spend cap (`spend_cap_exceeded`).
            The spend gate runs in-request, so this is returned before the
            message is queued. A `scheduled_at` more than one minute in the
            future defers the charge to dispatch time and so does not return
            402 here; a `scheduled_at` within that one-minute lead window is
            treated as send-now and is charged in-request, so it can return 402.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            The request conflicts with current state. This can mean the
            idempotency key was reused with a different payload
            (`idempotency_key_reused`) or the recipient is on the suppression
            list (`recipient_suppressed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429":
          description: |
            The account's request rate limit was exceeded
            (`rate_limit_exceeded`), or a `test_send` hit the per-recipient
            daily cap (`test_send_daily_limit`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: |
            `test_send: true` was passed but test sending is not configured on
            this deployment (`test_send_not_configured`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/send/batch:
    post:
      tags: [Send]
      summary: Send a batch of messages
      operationId: sendBatch
      description: |
        Send up to 1,000 messages in one call. Each item is processed
        independently. The response preserves request order, with per-item
        success or error (partial success is normal).

        Per-item failures are reported inside the `200` body, not as the HTTP
        status. A spend cap exhausted mid-batch surfaces as
        `spend_cap_exceeded` on the affected items, so check every item rather
        than the status code alone.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 1000
              items: { $ref: "#/components/schemas/SendRequest" }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Per-item results in request order.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BatchItemResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413":
          description: The batch exceeds 1,000 items (`batch_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/validate:
    post:
      tags: [Send]
      summary: Validate an email address
      operationId: validateEmail
      description: |
        Pre-flight check of a single address. Checks syntax and domain MX without
        sending. Returns `validation_not_configured` (503) when validation is
        unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, example: "ada@example.com" }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The validation result for the address.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailValidation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503":
          description: Email validation is not configured (`validation_not_configured`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/messages:
    get:
      tags: [Messages]
      summary: List messages
      operationId: listMessages
      description: |
        Returns messages newest-first with keyset pagination. Pass `limit`
        (default 50, max 100) and, to page, the opaque `next_cursor` from the
        previous response back as `cursor`. When `next_cursor` is `null` there
        are no more pages.

        Pass `search` to full-text search across the recipient and subject.
        Pass `idempotency_key` to look up the single message created with that
        key. When present, this short-circuits the listing and returns the full
        `Message` (with its event timeline), or `404` if no match exists.
      parameters:
        - { name: channel, in: query, schema: { $ref: "#/components/schemas/Channel" } }
        - { name: status, in: query, schema: { type: string } }
        - name: search
          in: query
          description: "Full-text query matched against the recipient and subject."
          schema: { type: string }
        - name: idempotency_key
          in: query
          description: |
            Look up the message created with this idempotency key. When set, the
            response is a single `Message` (not a page) and all other filters are
            ignored.
          schema: { type: string }
        - { name: from, in: query, description: "RFC3339 lower bound (inclusive).", schema: { type: string, format: date-time } }
        - { name: to, in: query, description: "RFC3339 upper bound (inclusive).", schema: { type: string, format: date-time } }
        - name: limit
          in: query
          description: "Max messages to return. Default 50, max 100."
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: "Opaque keyset cursor from a previous response's `next_cursor`."
          schema: { type: string }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            A page of message summaries plus the cursor for the next page. When
            `idempotency_key` is supplied, the body is a single `Message`
            instead.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [messages, next_cursor]
                    properties:
                      messages:
                        type: array
                        items: { $ref: "#/components/schemas/MessageSummary" }
                      next_cursor:
                        type: [string, "null"]
                        description: "Opaque cursor for the next page, or `null` on the last page."
                  - $ref: "#/components/schemas/Message"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/messages/{id}:
    get:
      tags: [Messages]
      summary: Get a message
      operationId: getMessage
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The message with its event timeline.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/messages/{id}/cancel:
    post:
      tags: [Messages]
      summary: Cancel a scheduled message
      operationId: cancelScheduledMessage
      description: |
        Cancel a message that was scheduled for future delivery. Only a message
        still in `scheduled` status can be cancelled; once it has left the
        scheduler there is nothing to cancel and the call returns `404`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The message was cancelled.
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id: { type: string }
                  status: { type: string, enum: [canceled], example: "canceled" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/inboxes:
    get:
      tags: [Mail]
      summary: List mail inboxes
      operationId: listMailInboxes
      description: |
        Return the configured user-facing inbox addresses for the account, such
        as support@example.com and billing@example.com.
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Configured mail inboxes.
          content:
            application/json:
              schema:
                type: object
                required: [inboxes]
                properties:
                  inboxes:
                    type: array
                    items: { $ref: "#/components/schemas/MailInbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Mail]
      summary: Create a mail inbox
      operationId: createMailInbox
      description: |
        Create a distinct receiving address on a verified, non-sandbox,
        inbound-enabled account domain. Existing catch-all receiving remains
        enabled; exact matches to this address are tagged with its inbox id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                address:
                  type: string
                  format: email
                  description: Full address to create. Alternative to domain plus local_part.
                domain:
                  type: string
                  description: Verified inbound domain. Required when address is omitted.
                local_part:
                  type: string
                  description: Mailbox name before @. Required when address is omitted.
                name:
                  type: string
                  description: Optional display label for the inbox.
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: Created inbox.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MailInbox" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /v1/mail/inboxes/{id}:
    delete:
      tags: [Mail]
      summary: Delete a mail inbox
      operationId: deleteMailInbox
      description: |
        Remove an inbox address. Existing messages keep their recipient address
        and thread membership, but are detached from the deleted inbox id.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Inbox deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/threads:
    get:
      tags: [Mail]
      summary: List mail threads
      operationId: listMailThreads
      description: |
        Return threaded inbox summaries across received and sent email. Filter
        by a configured inbox id or exact recipient address to show separate
        role inboxes like support@ and billing@. The `q` parameter supports
        plain text plus operators: `from:`, `to:`, `subject:`,
        `has:attachment`, `after:YYYY-MM-DD`, and `before:YYYY-MM-DD`.
        Mounted only when `MAIL_ENABLED=true`.
      parameters:
        - { name: folder, in: query, schema: { type: string, enum: [inbox, sent, archive, trash, spam] } }
        - { name: label, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string }, description: "Full-text search and operators, e.g. invoice from:alice@example.com has:attachment after:2026-06-01." }
        - { name: inbox_id, in: query, schema: { type: string }, description: Configured mail inbox id. }
        - { name: inbox, in: query, schema: { type: string, format: email }, description: Exact recipient address, e.g. support@example.com. }
        - { name: unread, in: query, schema: { type: boolean } }
        - { name: starred, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: A page of mail threads.
          content:
            application/json:
              schema:
                type: object
                required: [threads]
                properties:
                  threads:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: "thr_5c10" }
                        subject: { type: string }
                        snippet: { type: string }
                        participants: { type: array, items: { type: string } }
                        last_message_at: { type: string, format: date-time }
                        message_count: { type: integer }
                        has_unread: { type: boolean }
                        is_starred: { type: boolean }
                        labels: { type: array, items: { type: string } }
                  next_cursor: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/mail/search:
    get:
      tags: [Mail]
      summary: Search mail
      operationId: searchMail
      description: |
        Full-text search across inbound and outbound mail. Queries that only
        use operators are supported, so `q=from:alice@example.com` or
        `q=has:attachment` works without extra text. Combine q with inbox_id or
        inbox to search within a specific role inbox.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: inbox_id, in: query, schema: { type: string }, description: Configured mail inbox id. }
        - { name: inbox, in: query, schema: { type: string, format: email }, description: Exact recipient address. }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        thread_id: { type: string }
                        source: { type: string, enum: [inbound, outbound] }
                        message_ref: { type: string }
                        from: { type: string }
                        subject: { type: string }
                        snippet: { type: string }
                        occurred_at: { type: string, format: date-time }
                  next_cursor: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/mail/export:
    get:
      tags: [Mail]
      summary: Export mail
      operationId: exportMail
      description: |
        Export the current mailbox selection as JSON or mbox. Accepts the same
        filters as `/v1/mail/threads`, including role inbox filters and search
        operators. JSON includes attachment metadata; attachment bytes remain
        behind authenticated attachment download URLs.
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [json, mbox], default: json } }
        - { name: folder, in: query, schema: { type: string, enum: [inbox, sent, archive, trash, spam] } }
        - { name: label, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string }, description: "Plain text plus operators: from:, to:, subject:, has:attachment, after:, before:." }
        - { name: inbox_id, in: query, schema: { type: string }, description: Configured mail inbox id. }
        - { name: inbox, in: query, schema: { type: string, format: email }, description: Exact recipient address. }
        - { name: unread, in: query, schema: { type: boolean } }
        - { name: starred, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 5000 } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Exported mail data.
          content:
            application/json:
              schema:
                type: object
                required: [exported_at, count, messages]
                properties:
                  exported_at: { type: string, format: date-time }
                  count: { type: integer }
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/MailExportMessage" }
            application/mbox:
              schema:
                type: string
                format: binary
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/mail/threads/{id}:
    get:
      tags: [Mail]
      summary: Get a mail thread
      operationId: getMailThread
      description: Return a thread with inbound/outbound messages and mark it read.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Thread detail.
          content:
            application/json:
              schema:
                type: object
                required: [id, messages]
                properties:
                  id: { type: string }
                  subject: { type: string }
                  participants: { type: array, items: { type: string } }
                  last_message_at: { type: string, format: date-time }
                  message_count: { type: integer }
                  is_starred: { type: boolean }
                  labels: { type: array, items: { type: string } }
                  messages:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        source: { type: string, enum: [inbound, outbound] }
                        from: { type: string }
                        to: { type: array, items: { type: string } }
                        cc: { type: array, items: { type: string } }
                        subject: { type: string }
                        html_body: { type: string }
                        text_body: { type: string }
                        occurred_at: { type: string, format: date-time }
                        status: { type: string }
                        rfc_message_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/threads/{id}/read:
    post:
      tags: [Mail]
      summary: Set a mail thread's read state
      operationId: setMailThreadRead
      description: Mark every message in a thread as read or unread. An omitted body or `read` field defaults to `true`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                read: { type: boolean, default: true }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Read state updated.
          content:
            application/json:
              schema:
                type: object
                required: [id, read]
                properties:
                  id: { type: string }
                  read: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/threads/{id}/star:
    post:
      tags: [Mail]
      summary: Set a mail thread's starred state
      operationId: setMailThreadStarred
      description: Star or unstar every message in a thread. An omitted body or `starred` field defaults to `true`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                starred: { type: boolean, default: true }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Starred state updated.
          content:
            application/json:
              schema:
                type: object
                required: [id, starred]
                properties:
                  id: { type: string }
                  starred: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/messages/{source}/{id}/star:
    post:
      tags: [Mail]
      summary: Set a mail message's starred state
      operationId: setMailMessageStarred
      description: Star or unstar one inbound or outbound message. An omitted body or `starred` field defaults to `true`.
      parameters:
        - { name: source, in: path, required: true, schema: { type: string, enum: [inbound, outbound] } }
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                starred: { type: boolean, default: true }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Starred state updated.
          content:
            application/json:
              schema:
                type: object
                required: [source, id, starred]
                properties:
                  source: { type: string, enum: [inbound, outbound] }
                  id: { type: string }
                  starred: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/labels:
    get:
      tags: [Mail]
      summary: List mail labels
      operationId: listMailLabels
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: User-defined labels for the account.
          content:
            application/json:
              schema:
                type: object
                required: [labels]
                properties:
                  labels:
                    type: array
                    items: { $ref: "#/components/schemas/MailLabel" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Mail]
      summary: Create a mail label
      operationId: createMailLabel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                color: { type: string, description: "Optional UI color value." }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: Label created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MailLabel" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/mail/labels/{id}:
    delete:
      tags: [Mail]
      summary: Delete a mail label
      operationId: deleteMailLabel
      description: Delete a user-defined label and remove it from message state.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Label deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/compose:
    post:
      tags: [Mail]
      summary: Compose from Mail
      operationId: composeMail
      description: |
        Send a new email from the Mail app. Mail sends only from verified,
        non-sandbox account domains. One of `body_html` or `body_text` is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, format: email }
                from: { type: string, description: "Verified sender address or display-name address." }
                subject: { type: string }
                body_html: { type: string }
                body_text: { type: string }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201": { description: Queued send response., content: { application/json: { schema: { $ref: "#/components/schemas/SendResponse" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /v1/mail/threads/{id}/reply:
    post:
      tags: [Mail]
      summary: Reply to a mail thread
      operationId: replyMailThread
      description: Reply to the latest inbound message in a thread, preserving RFC threading headers.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                to: { type: string, format: email, description: "Optional override. Defaults to latest inbound sender." }
                from: { type: string, description: "Verified sender address or display-name address." }
                subject: { type: string }
                body_html: { type: string }
                body_text: { type: string }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201": { description: Queued send response., content: { application/json: { schema: { $ref: "#/components/schemas/SendResponse" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /v1/mail/threads/{id}/forward:
    post:
      tags: [Mail]
      summary: Forward a mail thread
      operationId: forwardMailThread
      description: |
        Send a thread to a new recipient, preserving RFC threading headers when
        an inbound parent is available. `to` is required, and one of `body_html`
        or `body_text` must be present. An omitted subject defaults to the latest
        inbound subject with an `Fwd:` prefix.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, format: email }
                from: { type: string, description: "Verified sender address or display-name address." }
                subject: { type: string }
                body_html: { type: string }
                body_text: { type: string }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201": { description: Queued send response., content: { application/json: { schema: { $ref: "#/components/schemas/SendResponse" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /v1/mail/threads/{id}/move:
    post:
      tags: [Mail]
      summary: Move a mail thread
      operationId: moveMailThread
      description: Move every message in a thread into inbox, archive, trash, or spam. sent is derived from outbound messages and is not a move target.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [folder]
              properties:
                folder: { type: string, enum: [inbox, archive, trash, spam], description: "Target folder." }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200": { description: Moved., content: { application/json: { schema: { type: object, properties: { id: { type: string }, folder: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/mail/threads/{id}/labels:
    post:
      tags: [Mail]
      summary: Update mail thread labels
      operationId: labelMailThread
      description: Add or remove existing labels across a thread. Unknown label names are ignored. Create labels first with POST /v1/mail/labels.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                add: { type: array, items: { type: string }, description: "Label names to add." }
                remove: { type: array, items: { type: string }, description: "Label names to remove." }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200": { description: Updated., content: { application/json: { schema: { type: object, properties: { id: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/usage:
    get:
      tags: [Usage]
      summary: Get usage
      operationId: getUsage
      parameters:
        - { name: period, in: query, description: "Billing period YYYY-MM (defaults to current).", schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Usage summary for the period.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/suppressions:
    get:
      tags: [Suppressions]
      summary: List suppressed recipients
      operationId: listSuppressions
      parameters:
        - { name: channel, in: query, schema: { $ref: "#/components/schemas/Channel" } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Suppressed/unsubscribed recipients.
          content:
            application/json:
              schema:
                type: object
                required: [suppressions]
                properties:
                  suppressions:
                    type: array
                    items: { $ref: "#/components/schemas/Suppression" }
    post:
      tags: [Suppressions]
      summary: Suppress a recipient
      operationId: addSuppression
      description: Adds a recipient to the suppression list, blocking all sends to it on the channel.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, recipient]
              properties:
                channel: { $ref: "#/components/schemas/Channel" }
                recipient: { type: string }
                reason: { type: string }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created suppression.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Suppression" }
    delete:
      tags: [Suppressions]
      summary: Remove a suppression (un-suppress)
      operationId: removeSuppression
      parameters:
        - { name: channel, in: query, required: true, schema: { $ref: "#/components/schemas/Channel" } }
        - { name: recipient, in: query, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/suppressions/import:
    post:
      tags: [Suppressions]
      summary: Bulk-import suppressions
      operationId: importSuppressions
      description: |
        Bulk-add suppressed recipients when migrating a deny-list from another
        provider. Accepts up to 10,000 entries per call.
        Addresses are normalized and de-duplicated, then idempotently inserted.
        `suppressed` blocks every email. Requires an admin-scoped key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entries]
              properties:
                channel: { $ref: "#/components/schemas/Channel" }
                entries:
                  type: array
                  maxItems: 10000
                  items:
                    type: object
                    required: [email]
                    properties:
                      email: { type: string }
                      reason: { type: string }
                      state: { type: string, enum: [suppressed], default: suppressed }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: |
            Import result. `imported` counts newly-added recipients;
            `skipped` counts blank, duplicate, and already-present inputs.
          content:
            application/json:
              schema:
                type: object
                required: [imported, skipped]
                properties:
                  imported: { type: integer, example: 940 }
                  skipped: { type: integer, example: 60 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: The batch exceeds the 10,000-entry limit (`invalid_request`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/billing:
    get:
      tags: [Billing]
      summary: Get billing state
      operationId: getBilling
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's transactional email plan and included volume.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { type: string, example: "pro" }
                  subscription_status: { type: string, example: "active" }
                  included_allotment: { type: integer, example: 50000 }
                  recurring_interval: { type: string, enum: [month, year], example: "month" }

  /v1/billing/checkout:
    post:
      tags: [Billing]
      summary: Start a checkout
      operationId: createCheckout
      description: |
        Start a Polar-hosted checkout for a transactional email plan. An omitted
        plan defaults to Pro and an omitted period defaults to `month`; unknown
        values return `400`. If the account already has an active subscription,
        Sendara changes that subscription in place instead.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                plan:
                  type: string
                  enum: [starter, pro, growth, scale]
                  default: pro
                period:
                  type: string
                  enum: [month, year]
                  default: month
                  description: A yearly subscription uses one annual Polar billing cycle and one 12× included-volume pool; it does not reset monthly.
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "400":
          description: Invalid JSON, plan, or period.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "200":
          description: A hosted checkout URL, or confirmation that an active subscription was changed.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [url]
                    properties: { url: { type: string, format: uri } }
                  - type: object
                    required: [updated, plan]
                    properties:
                      updated: { type: boolean, const: true }
                      plan: { type: string, enum: [starter, pro, growth, scale] }
        "503":
          description: Billing is not configured, or the exact Polar product selected is temporarily unavailable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/billing/portal:
    post:
      tags: [Billing]
      summary: Open the customer portal
      operationId: createPortal
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Customer portal URL.
          content:
            application/json:
              schema: { type: object, properties: { url: { type: string } } }
        "503":
          description: Billing is not configured (`billing_not_configured`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/account/verification:
    get:
      tags: [Account]
      summary: Get channel verification status
      operationId: getAccountVerification
      description: |
        Per-channel sending readiness for the account: whether email (and, when
        enabled, SMS/push/voice/webhook) is verified and can send, plus whether
        the account is still in sandbox mode and the shared platform sender used
        while it is. Use this to decide, before sending, whether a real `from`
        address is available or the send falls back to the sandbox sender.
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's per-channel verification status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountVerification" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/keys:
    get:
      tags: [API Keys]
      summary: List API keys
      operationId: listApiKeys
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's API keys (no secrets).
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [API Keys]
      summary: Create an API key
      operationId: createApiKey
      description: The plaintext key is returned exactly once. Store it securely.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scope: { $ref: "#/components/schemas/Scope" }
                test_mode: { type: boolean, default: false }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created key, including the one-time plaintext secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedApiKey" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/keys/{id}/rotate:
    post:
      tags: [API Keys]
      summary: Rotate an API key
      operationId: rotateApiKey
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The new plaintext key.
          content:
            application/json:
              schema:
                type: object
                required: [key]
                properties: { key: { type: string } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/keys/{id}:
    delete:
      tags: [API Keys]
      summary: Revoke an API key
      operationId: revokeApiKey
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Revoked. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains:
    get:
      tags: [Domains]
      summary: List domains
      operationId: listDomains
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's sending domains.
          content:
            application/json:
              schema:
                type: object
                required: [domains]
                properties:
                  domains:
                    type: array
                    items: { $ref: "#/components/schemas/Domain" }
    post:
      tags: [Domains]
      summary: Add a sending domain
      operationId: createDomain
      description: |
        Registers the domain with the email provider and returns the DNS records
        to publish (3 DKIM CNAMEs, a custom MAIL FROM MX + SPF, and DMARC).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: "mail.acme.com" }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created domain with DNS records to publish.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "409":
          description: Domain already registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/domains/{domain}:
    get:
      tags: [Domains]
      summary: Get a domain
      operationId: getDomain
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Domains]
      summary: Delete a domain
      operationId: deleteDomain
      description: |
        Remove a sending domain and best-effort tear down its provider identity.
        The sandbox domain cannot be deleted and returns `422
        cannot_delete_sandbox`. Requires an admin-scoped key.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The sandbox domain cannot be deleted (`cannot_delete_sandbox`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/domains/{domain}/dns-setup:
    get:
      tags: [Domains]
      summary: Get provider-aware DNS setup
      operationId: getDomainDnsSetup
      description: |
        Return the DNS records to publish for the domain, plus the detected DNS
        provider and provider-tailored guidance (dashboard deep link, whether the
        host must be entered relative to the zone apex, and the provider's field
        labels), so the dashboard can show exactly what to paste and where.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The DNS setup guide for the domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DnsSetup" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/dns-setup/zonefile:
    get:
      tags: [Domains]
      summary: Download DNS records as a zone file
      operationId: getDomainZonefile
      description: |
        Return the domain's DNS records as a downloadable BIND-format zone file
        fragment, so they can be bulk-imported instead of copied field by field.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: BIND-format zone file fragment.
          content:
            text/plain:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/verify:
    post:
      tags: [Domains]
      summary: Verify a domain
      operationId: verifyDomain
      description: Re-checks DNS/SES status and returns the per-record result.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Verification result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainVerification" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/bimi:
    get:
      tags: [Domains]
      summary: Get BIMI state
      operationId: getBimi
      description: |
        Returns the domain's BIMI (brand logo) state: the hosted logo URL, the
        TXT record to publish (`null` until a logo is set), whether the domain's
        published DMARC policy is `p=quarantine` or `p=reject` with a recommended
        policy, whether the record is live, and a note on which mailbox providers
        require a VMC.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The domain's BIMI state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BimiState" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Domains]
      summary: Set the BIMI logo URL
      operationId: setBimi
      description: |
        Point BIMI at a square SVG Tiny PS logo served over HTTPS. Sendara
        validates the URL and returns the BIMI state, including the generated
        TXT record.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [logo_url]
              properties:
                logo_url:
                  type: string
                  description: Public HTTPS URL of a square SVG Tiny PS logo.
                  example: "https://cdn.acme.com/brand/logo-tiny-ps.svg"
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated BIMI state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BimiState" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/bimi/logo:
    post:
      tags: [Domains]
      summary: Upload a BIMI logo
      operationId: uploadBimiLogo
      description: |
        Upload a BIMI logo as `multipart/form-data` with a `file` part (SVG,
        max 1 MiB). Sendara validates the SVG Tiny PS profile, hosts the logo on
        a stable HTTPS URL, and returns the BIMI state.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated BIMI state with the hosted logo URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BimiState" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413":
          description: The SVG exceeds the 1 MiB limit (`payload_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/domains/{domain}/from-name:
    put:
      tags: [Domains]
      summary: Set the default sender name
      operationId: setDomainFromName
      description: |
        Set (or clear, when empty) the default sender display name for a verified
        domain. Every send from the domain that does not carry its own
        `"Name" <email>` override on `from_email` then shows this name in
        recipients' inboxes. Max 78 characters. Line breaks are rejected.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [default_from_name]
              properties:
                default_from_name:
                  type: string
                  maxLength: 78
                  description: Display name shown in inboxes. Empty clears the default.
                  example: "Acme Receipts"
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates:
    get:
      tags: [Templates]
      summary: List templates
      operationId: listTemplates
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's templates.
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
    post:
      tags: [Templates]
      summary: Create a template
      operationId: createTemplate
      description: |
        Create a reusable template. Content uses mustache `{{ variable }}`
        placeholders. Declare each in `variables` with optional `sample`,
        `default`, and `required` metadata.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateInput" }
            examples:
              email:
                summary: Email template
                value:
                  name: "Welcome"
                  channel: email
                  subject: "Welcome, {{ first_name }}"
                  body_html: "<h1>Hi {{ first_name }}</h1>"
                  variables:
                    - { name: first_name, sample: "Ada", required: true }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/templates/{id}:
    get:
      tags: [Templates]
      summary: Get a template
      operationId: getTemplate
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Templates]
      summary: Update a template
      operationId: updateTemplate
      description: Updating content bumps the template `version`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateUpdate" }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Templates]
      summary: Delete a template
      operationId: deleteTemplate
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The template was deleted.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates/{id}/render:
    post:
      tags: [Templates]
      summary: Render a template
      operationId: renderTemplate
      description: |
        Render the template with the supplied `vars` and return the resulting
        channel payload (e.g. `{subject, body_html, body_text}` for email)
        without sending. Missing required variables return `missing_variable`;
        a malformed template returns `invalid_template`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vars:
                  type: object
                  additionalProperties: true
                  description: "Variable values keyed by name."
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The rendered channel payload.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: "Channel payload, e.g. `{subject, body_html, body_text}` for email."
        "400":
          description: A required variable was missing or the template is invalid.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates/snippets:
    get:
      tags: [Templates]
      summary: List snippets
      operationId: listSnippets
      description: Reusable partials that can be included in templates and previews.
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's snippets.
          content:
            application/json:
              schema:
                type: object
                required: [snippets]
                properties:
                  snippets:
                    type: array
                    items: { $ref: "#/components/schemas/Snippet" }
    post:
      tags: [Templates]
      summary: Create a snippet
      operationId: createSnippet
      description: Create a reusable partial. Requires an admin-scoped key.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SnippetInput" }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created snippet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Snippet" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/templates/snippets/{id}:
    get:
      tags: [Templates]
      summary: Get a snippet
      operationId: getSnippet
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The snippet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Snippet" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Templates]
      summary: Update a snippet
      operationId: updateSnippet
      description: Requires an admin-scoped key.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SnippetInput" }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated snippet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Snippet" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Templates]
      summary: Delete a snippet
      operationId: deleteSnippet
      description: Requires an admin-scoped key.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates/preview:
    post:
      tags: [Templates]
      summary: Preview unsaved template content
      operationId: previewTemplate
      description: |
        Render arbitrary, unsaved template source with sample `variables` and the
        account's snippets, returning the rendered `subject`, `html`, and `text`
        plus any `warnings` (e.g. variables referenced but not provided) and render
        `errors`. Nothing is stored or sent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                channel: { $ref: "#/components/schemas/TemplateChannel" }
                subject: { type: string }
                body_html: { type: string }
                body_text: { type: string }
                variables:
                  type: object
                  additionalProperties: true
                  description: "Variable values keyed by name."
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The rendered preview with warnings and errors.
          content:
            application/json:
              schema:
                type: object
                required: [subject, html, text, warnings, errors]
                properties:
                  subject: { type: string }
                  html: { type: string }
                  text: { type: string }
                  warnings: { type: array, items: { type: string } }
                  errors: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/templates/gallery:
    get:
      tags: [Templates]
      summary: List starter templates
      operationId: listTemplateGallery
      description: Built-in starter templates you can copy from, each with sample variables.
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The built-in template gallery.
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/GalleryTemplate" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      operationId: listWebhooks
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's webhook subscriptions. Signing secrets are omitted.
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookSubscription" }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      operationId: createWebhook
      description: |
        Subscribe an HTTPS endpoint to event callbacks. Omit `event_types` (or
        send an empty array) to receive all event types. This response includes
        the `signing_secret` used to verify the `Sendara-Signature` header on
        every delivery. Store it securely: later list, get, and update responses
        omit it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint_url]
              properties:
                endpoint_url: { type: string, format: uri, example: "https://acme.com/webhooks/sendara" }
                event_types:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                  description: "Event types to subscribe to. Empty = all."
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created subscription, including its signing secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscriptionWithSecret" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Get a webhook subscription
      operationId: getWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The subscription. Signing secrets are omitted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscription" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Webhooks]
      summary: Update a webhook subscription
      operationId: updateWebhook
      description: Change the endpoint, event-type filter, or pause/resume via `is_active`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                endpoint_url: { type: string, format: uri }
                event_types:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                is_active: { type: boolean }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated subscription. Signing secrets are omitted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscription" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The subscription was deleted.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List delivery attempts
      operationId: listWebhookDeliveries
      description: |
        Returns recent delivery attempts for the subscription, newest first. Useful for debugging failed callbacks.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: Recent delivery records.
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate the signing secret
      operationId: rotateWebhookSecret
      description: |
        Generates and returns a new `signing_secret` once. The API never returns
        the previous secret, so keep your stored old value while handlers roll
        out the new value and temporarily accept either at the receiver.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The subscription with its new signing secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscriptionWithSecret" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/test-recipients:
    get:
      tags: [Test Recipients]
      summary: List test recipients
      operationId: listTestRecipients
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's registered test recipients.
          content:
            application/json:
              schema:
                type: object
                required: [recipients]
                properties:
                  recipients:
                    type: array
                    items: { $ref: "#/components/schemas/TestRecipient" }
    post:
      tags: [Test Recipients]
      summary: Register a test recipient
      operationId: createTestRecipient
      description: |
        Register one of your own addresses (up to 3 per account). A verification
        email is sent. Once the recipient confirms, you can send free real test
        emails to it with `test_send: true` (capped 10/recipient/day).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The pending test recipient. A verification email was sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TestRecipient" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422":
          description: The 3-recipient cap was reached (`too_many_test_recipients`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/test-recipients/{id}/resend:
    post:
      tags: [Test Recipients]
      summary: Resend the verification email
      operationId: resendTestRecipientVerification
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: A new verification email was sent. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/test-recipients/{id}:
    delete:
      tags: [Test Recipients]
      summary: Remove a test recipient
      operationId: deleteTestRecipient
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/uploads:
    post:
      tags: [Uploads]
      summary: Upload an image
      operationId: createUpload
      description: |
        Upload an image (PNG, JPEG, GIF, or WebP, max 2 MiB) as
        `multipart/form-data` with a `file` part. Returns a stable public URL
        you can embed in email HTML.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The stored asset and its public URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Upload" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "413":
          description: The file exceeds the 2 MiB limit (`payload_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/spend-caps:
    put:
      tags: [Spend Caps]
      summary: Set a spend cap
      operationId: setSpendCap
      description: |
        Set or update a spend cap. Omit `key_id` to cap the whole account, or
        provide a `key_id` to cap a single API key. The `soft_limit_micros`
        warns. The `hard_limit_micros` blocks further sends with
        `spend_cap_exceeded` (402). Values are in micro-dollars (1,000,000 = $1).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                key_id:
                  type: string
                  description: "Cap this key only. Omit for the account-wide cap."
                soft_limit_micros: { type: [integer, "null"], minimum: 0 }
                hard_limit_micros: { type: [integer, "null"], minimum: 0 }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The applied spend cap.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpendCap" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The referenced API key was not found.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/inbound:
    get:
      tags: [Inbound]
      summary: List received emails
      operationId: listInboundEmails
      description: |
        List received inbound emails, newest first. Bodies (`text`, `html`) are
        omitted from the list to keep the payload light; fetch a single message
        for the full content. Available only when inbound receiving is enabled.
      parameters:
        - { name: limit, in: query, description: "Max emails to return.", schema: { type: integer } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's received emails (bodies omitted).
          content:
            application/json:
              schema:
                type: object
                required: [emails]
                properties:
                  emails:
                    type: array
                    items: { $ref: "#/components/schemas/InboundEmail" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/inbound/routes:
    get:
      tags: [Inbound]
      summary: List inbound routes
      operationId: listInboundRoutes
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The account's inbound forwarding routes.
          content:
            application/json:
              schema:
                type: object
                required: [routes]
                properties:
                  routes:
                    type: array
                    items: { $ref: "#/components/schemas/InboundRoute" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Inbound]
      summary: Create an inbound route
      operationId: createInboundRoute
      description: |
        Forward inbound mail for a local-part (or `*` catch-all) on a verified,
        inbound-enabled domain, in addition to the account's `email.received`
        webhooks. Set `destination_type` to `webhook` (requires an HTTPS
        `forward_url`) or `email` (requires a valid `forward_address`). The
        `signing_secret` that signs forwarded webhook POSTs is returned only
        once, in this response. Requires an admin-scoped key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string }
                match_prefix:
                  type: string
                  description: 'Local-part to match, or "*" for the domain catch-all.'
                destination_type:
                  type: string
                  enum: [webhook, email]
                  default: webhook
                forward_url:
                  type: string
                  description: "HTTPS endpoint for `webhook` routes."
                forward_address:
                  type: string
                  description: "Destination inbox for `email` routes."
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The created route, including the one-time `signing_secret`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InboundRoute" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422":
          description: The domain is not verified, inbound-enabled, and non-sandbox (`domain_not_ready`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/inbound/routes/{id}:
    put:
      tags: [Inbound]
      summary: Update an inbound route
      operationId: updateInboundRoute
      description: Update a route's match prefix, destination, or active state. Only supplied fields change.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                match_prefix: { type: string }
                forward_url: { type: string }
                forward_address: { type: string }
                active: { type: boolean }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated route.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InboundRoute" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Inbound]
      summary: Delete an inbound route
      operationId: deleteInboundRoute
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/inbound/{id}:
    get:
      tags: [Inbound]
      summary: Get a received email
      operationId: getInboundEmail
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The full received email, including bodies and attachment metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InboundEmail" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/inbound/{id}/raw:
    get:
      tags: [Inbound]
      summary: Download the raw MIME message
      operationId: getInboundEmailRaw
      description: Return the original RFC 822 message as stored at receipt.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The raw MIME message.
          content:
            message/rfc822:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/inbound/{id}/attachments/{index}:
    get:
      tags: [Inbound]
      summary: Download an attachment
      operationId: downloadInboundAttachment
      description: Download the attachment at the given zero-based index from a received email.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: index, in: path, required: true, description: "Zero-based attachment index.", schema: { type: integer } }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The attachment bytes, served with the stored content type.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{id}/inbound:
    post:
      tags: [Inbound]
      summary: Enable or disable inbound receiving
      operationId: setDomainInbound
      description: |
        Turn inbound email receiving on or off for a domain. The domain must be a
        verified, non-sandbox domain. When enabled, publish the returned
        `mx_record` to start receiving. Requires an admin-scoped key.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        "429": { $ref: "#/components/responses/RateLimited" }
        "200":
          description: The updated receiving state and the MX record to publish.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  mx_record:
                    type: object
                    properties:
                      type: { type: string, example: "MX" }
                      priority: { type: string, example: "10" }
                      value: { type: string, example: "inbound-smtp.us-east-1.amazonaws.com" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The domain must be verified and non-sandbox (`domain_not_ready`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key as a Bearer token: `Authorization: Bearer sk_live_...`"

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: The key's scope does not permit this operation.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BadRequest:
      description: Malformed request.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unprocessable:
      description: The request was understood but cannot be processed (e.g. unverified from address).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: The resource already exists.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: |
        Too many requests (`rate_limit_exceeded`). Every authenticated endpoint
        is rate limited per account. `Retry-After` gives the seconds to wait and
        the `X-RateLimit-Limit`/`-Remaining`/`-Reset` headers carry the budget.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Channel:
      type: string
      description: |
        The messaging channel. Email is the only generally available channel
        today. Sends, templates, suppressions, and usage are all email.
      enum: [email]
    Scope:
      type: string
      enum: [send, read, admin]
    MessageType:
      type: string
      enum: [transactional]
      default: transactional

    SendRequest:
      type: object
      required: [channel, idempotency_key, destination]
      description: |
        Supply either `payload` for inline content or `template_id` to render a
        stored template. The API does not currently reject a request that
        carries neither, so treat that as a client-side error: a validating
        proxy must not assume one of the two is present.
      properties:
        channel: { $ref: "#/components/schemas/Channel" }
        idempotency_key:
          type: string
          description: |
            Required for raw HTTP calls. Official SDKs generate one when omitted.
            Reusing a key with a different body returns
            `idempotency_key_reused` (409).
        message_type: { $ref: "#/components/schemas/MessageType" }
        destination:
          type: object
          description: "The recipient: `{email}`."
          additionalProperties: true
        payload:
          type: object
          description: "Email content: `{subject, body_html, body_text}`."
          additionalProperties: true
        template_id:
          type: string
          description: Optional template to render instead of an inline payload.
        template_vars:
          type: object
          additionalProperties: true
        scheduled_at:
          type: string
          format: date-time
          description: RFC 3339 dispatch time up to 90 days ahead. More than one minute ahead returns `scheduled`; at or within one minute ahead enters the normal queue immediately.
        validate_recipient:
          type: boolean
          default: false
          description: Check recipient syntax and the domain's MX, A, or AAAA records before accepting the send.
        metadata:
          type: object
          description: |
            Per-send options. The key field is `from_email`, the sender shown
            in the recipient's inbox.

            `from_email` is **required when sending from your own verified
            domain**; omitting it returns `422 from_required`. The domain must
            be a verified sending domain, otherwise the send fails with
            `422 from_not_verified`.

            Sandbox accounts (no verified domain) omit `from_email` entirely and
            are sent from Sendara's shared platform sender.

            Accepted formats:
            - Bare address: `support@foliodb.space`
            - RFC 5322 display name (name first, address in angle brackets):
              `Folio DB <support@foliodb.space>`. Quote the name if it contains
              commas or special characters: `"Folio DB, Inc." <support@foliodb.space>`.
              The inverted form `<Folio DB> support@foliodb.space` is invalid.
          additionalProperties: true
          properties:
            from_email:
              type: string
              description: |
                Sender address for own-domain sends. Required when the account
                has a verified sending domain. Omitted for sandbox accounts.
                Accepts a bare address or an RFC 5322 display-name address.
              examples:
                bare:
                  summary: Bare address
                  value: "support@foliodb.space"
                displayName:
                  summary: Display name (name first, angle brackets)
                  value: "Folio DB <support@foliodb.space>"
        store_payload:
          type: boolean
          default: true
          description: |
            Whether to retain the rendered content after sending. Defaults to
            true (a copy is kept). Set false to redact the stored payload once
            the message is dispatched. The email content is still dispatched, but the
            content is not retained.
        test_send:
          type: boolean
          default: false
          description: |
            Route the send through the verified-test-recipient path. The
            destination must be one of your verified test recipients (see
            `/v1/test-recipients`). The email is delivered for real, free, and
            capped at 10/recipient/day. Fails with `recipient_not_verified` or
            `test_send_daily_limit` otherwise.

    SendResponse:
      type: object
      required: [id, status, channel, idempotency_key, created_at]
      properties:
        id: { type: string, example: "msg_a1b2c3" }
        status: { type: string, example: "queued" }
        channel: { $ref: "#/components/schemas/Channel" }
        idempotency_key: { type: string }
        created_at: { type: string, format: date-time }

    MailInbox:
      type: object
      required: [id, domain_id, domain, local_part, address, active, created_at, updated_at]
      properties:
        id: { type: string, example: "inbox_a1b2c3" }
        domain_id: { type: string, example: "dom_abc123" }
        domain: { type: string, example: "mail.acme.com" }
        local_part: { type: string, example: "support" }
        address: { type: string, format: email, example: "support@mail.acme.com" }
        name: { type: string, example: "Support" }
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    MailLabel:
      type: object
      required: [id, name, color, created_at]
      properties:
        id: { type: string, example: "lbl_a1b2c3" }
        name: { type: string, example: "Important" }
        color: { type: string, example: "#f59e0b" }
        created_at: { type: string, format: date-time }

    MailExportMessage:
      type: object
      required: [id, thread_id, source, from, to, subject, occurred_at]
      properties:
        id: { type: string }
        thread_id: { type: string }
        source: { type: string, enum: [inbound, outbound] }
        from: { type: string }
        to: { type: array, items: { type: string } }
        cc: { type: array, items: { type: string } }
        subject: { type: string }
        text_body: { type: string }
        html_body: { type: string }
        occurred_at: { type: string, format: date-time }
        status: { type: string }
        labels: { type: array, items: { type: string } }
        attachments:
          type: array
          description: Attachment metadata only. Use the authenticated attachment download endpoint for bytes.
          items:
            type: object
            properties:
              filename: { type: string }
              content_type: { type: string }
              size: { type: integer }
              s3_key: { type: string }

    BatchItemResult:
      type: object
      required: [success]
      properties:
        success: { type: boolean }
        response: { $ref: "#/components/schemas/SendResponse" }
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            status: { type: integer }

    MessageSummary:
      type: object
      required: [id, channel, status, to, subject, message_type, created_at]
      properties:
        id: { type: string }
        channel: { $ref: "#/components/schemas/Channel" }
        status:
          type: string
          example: "delivered"
          description: |
            Free-text lifecycle status. Common values: `queued`, `sent`,
            `delivered`, `delivery_delayed` (provider deferred delivery),
            `bounced`, `failed`, `complained`, and `suppressed` (blocked by the
            suppression list). A suppressed message is never enqueued or billed.
        to:
          type: string
          description: "Primary recipient (email address for the email channel)."
        subject:
          type: string
          description: "Message subject. Empty for channels without one."
        message_type: { type: string }
        created_at: { type: string, format: date-time }

    Message:
      type: object
      required: [id, account_id, channel, status, destination, payload, message_type, idempotency_key, created_at, updated_at]
      properties:
        id: { type: string }
        account_id: { type: string }
        channel: { $ref: "#/components/schemas/Channel" }
        status:
          type: string
          example: "delivered"
          description: |
            Free-text lifecycle status. Common values: `queued`, `sent`,
            `delivered`, `delivery_delayed` (provider deferred delivery),
            `bounced`, `failed`, `complained`, and `suppressed` (blocked by the
            suppression list). A suppressed message is never enqueued or billed.
        destination:
          type: object
          description: "Resolved destination, e.g. `{ \"email\": \"user@example.com\" }`."
        payload:
          type: object
          description: "The rendered send payload (subject, body, and template data)."
        message_type: { type: string }
        template_id: { type: [string, "null"] }
        idempotency_key: { type: string }
        provider_message_id: { type: [string, "null"] }
        metadata:
          type: object
          description: "Caller-supplied metadata echoed back on the message."
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        events:
          type: array
          items: { $ref: "#/components/schemas/MessageEvent" }

    MessageEvent:
      type: object
      properties:
        id: { type: string }
        type:
          type: string
          example: "delivered"
          description: |
            Timeline event type: `sent`, `delivered`, `delivery_delayed`,
            `bounced`, `failed`, `opened`, `clicked`, `complained`, `suppressed`,
            or `unsubscribed`. `queued` is a message status, not a timeline event.
        occurred_at: { type: string, format: date-time }

    Usage:
      type: object
      required: [period, total_send_count, total_cost_micros, channels]
      properties:
        period: { type: string, example: "2026-06" }
        total_send_count: { type: integer }
        total_cost_micros:
          type: integer
          description: Internal estimated-spend ledger used for spend-cap enforcement, in micro-dollars (1,000,000 = $1.00). It is not an invoice or the plan-specific overage price.
        channels:
          type: array
          items:
            type: object
            properties:
              channel: { type: string, enum: [email, sms, voice, push, webhook] }
              send_count: { type: integer }
              cost_micros: { type: integer, description: "The channel's internal spend-cap estimate, not its invoiced overage." }

    Suppression:
      type: object
      properties:
        channel: { $ref: "#/components/schemas/Channel" }
        recipient: { type: string }
        state: { type: string, enum: [suppressed, unsubscribed], example: "suppressed" }
        reason: { type: string }
        updated_at: { type: string, format: date-time }

    ApiKey:
      type: object
      properties:
        id: { type: string }
        key_prefix: { type: string }
        scope: { $ref: "#/components/schemas/Scope" }
        name: { type: string }
        is_revoked: { type: boolean }
        test_mode: { type: boolean }
        last_used_at: { type: [string, "null"], format: date-time }
        request_count: { type: integer }
        created_at: { type: string, format: date-time }

    CreatedApiKey:
      type: object
      required: [id, key, key_prefix, scope, test_mode, created_at]
      properties:
        id: { type: string }
        key:
          type: string
          description: The plaintext secret. Shown only once.
        key_prefix: { type: string }
        scope: { $ref: "#/components/schemas/Scope" }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }

    VerificationStatus:
      type: string
      enum: [pending, verified, failed]

    DnsRecord:
      type: object
      properties:
        type: { type: string, example: "CNAME" }
        name: { type: string }
        value: { type: string }

    Domain:
      type: object
      properties:
        id: { type: string }
        account_id: { type: string }
        domain: { type: string }
        dkim_status: { $ref: "#/components/schemas/VerificationStatus" }
        spf_status: { $ref: "#/components/schemas/VerificationStatus" }
        dmarc_status: { $ref: "#/components/schemas/VerificationStatus" }
        txt_status: { $ref: "#/components/schemas/VerificationStatus" }
        dns_records:
          type: array
          items: { $ref: "#/components/schemas/DnsRecord" }
        is_sandbox: { type: boolean }
        mail_from_domain: { type: string }
        bimi_logo_url: { type: string, format: uri }
        default_from_name:
          type: string
          description: Default sender display name shown in inboxes for sends from this domain.
        inbound_enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DomainVerification:
      type: object
      properties:
        domain: { type: string }
        fully_verified: { type: boolean }
        results:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              type: { type: string }
              name: { type: string }
              status: { $ref: "#/components/schemas/VerificationStatus" }
              detail: { type: string }

    BimiState:
      type: object
      properties:
        logo_url:
          type: string
          nullable: true
          description: The hosted (or supplied) HTTPS URL of the BIMI logo.
        record:
          nullable: true
          description: The TXT record to publish, or null until a logo is set.
          allOf:
            - $ref: "#/components/schemas/DnsRecord"
        dmarc:
          type: object
          properties:
            at_enforcement:
              type: boolean
              description: True when the published DMARC policy is p=quarantine or p=reject.
            recommended:
              type: string
              description: A DMARC policy value to copy, at p=quarantine or stricter.
        bimi_published:
          type: boolean
          description: True when the BIMI TXT record is live in DNS.
        vmc_note:
          type: string
          description: Which mailbox providers require a paid VMC certificate.

    EventType:
      type: string
      description: |
        A webhook subscription event type. `queued` is accepted as a filter for
        compatibility but is a message status only, so Sendara emits no queued
        webhook. `suppressed` and `unsubscribed` are timeline-only router events
        and are not valid subscription types.
      enum: [queued, sent, delivered, failed, bounced, opened, clicked, complained, delivery_delayed, email.received]

    TemplateChannel:
      type: string
      description: "email is the only generally available template channel today."
      enum: [email]

    TemplateVariable:
      type: object
      description: Declares a `{{ name }}` placeholder used in the template.
      required: [name]
      properties:
        name: { type: string, example: "first_name" }
        sample: { type: string, description: "Example value used in previews." }
        default: { type: string, description: "Fallback when the variable is omitted." }
        required: { type: boolean, description: "If true, rendering fails when the value is missing." }

    TemplateInput:
      type: object
      required: [name, channel]
      properties:
        name: { type: string }
        channel: { $ref: "#/components/schemas/TemplateChannel" }
        subject: { type: [string, "null"], description: "Email subject (email only)." }
        body_text: { type: [string, "null"] }
        body_html: { type: [string, "null"] }
        body_json:
          type: object
          additionalProperties: true
          description: "Structured (block-editor) body, if used."
        variables:
          type: array
          items: { $ref: "#/components/schemas/TemplateVariable" }

    TemplateUpdate:
      type: object
      description: Partial update. Only the fields you send are changed.
      properties:
        name: { type: string }
        subject: { type: [string, "null"] }
        body_text: { type: [string, "null"] }
        body_html: { type: [string, "null"] }
        body_json:
          type: object
          additionalProperties: true
        variables:
          type: array
          items: { $ref: "#/components/schemas/TemplateVariable" }
        is_active: { type: boolean }

    Template:
      type: object
      properties:
        id: { type: string, example: "tmpl_a1b2c3" }
        account_id: { type: string }
        name: { type: string }
        channel: { $ref: "#/components/schemas/TemplateChannel" }
        subject: { type: [string, "null"] }
        body_text: { type: [string, "null"] }
        body_html: { type: [string, "null"] }
        body_json:
          type: object
          additionalProperties: true
        variables:
          type: array
          items: { $ref: "#/components/schemas/TemplateVariable" }
        version: { type: integer }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookSubscription:
      type: object
      properties:
        id: { type: string, example: "whsub_a1b2c3" }
        account_id: { type: string }
        endpoint_url: { type: string, format: uri }
        event_types:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
          description: "Subscribed event types. Empty means all."
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookSubscriptionWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookSubscription"
        - type: object
          required: [signing_secret]
          properties:
            signing_secret:
              type: string
              description: "The new secret for verifying `Sendara-Signature`. Returned once by create or rotate; store it securely."

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        subscription_id: { type: string }
        event_id: { type: string }
        event_type: { $ref: "#/components/schemas/EventType" }
        payload:
          allOf:
            - $ref: "#/components/schemas/WebhookEventPayload"
          description: "The event body that was (or will be) POSTed to the endpoint."
        status: { type: string, enum: [pending, succeeded, failed, exhausted] }
        attempt_count: { type: integer }
        next_retry_at: { type: [string, "null"], format: date-time }
        response_status: { type: [integer, "null"], description: "HTTP status returned by the endpoint." }
        created_at: { type: string, format: date-time }

    WebhookEventPayload:
      type: object
      description: |
        The JSON body POSTed to your endpoint for each event. Verify it with the
        `Sendara-Signature` header (see the webhook security section).
      properties:
        event_id: { type: string, example: "evt_a1b2c3" }
        event_type: { $ref: "#/components/schemas/EventType" }
        message_id: { type: string, example: "msg_a1b2c3" }
        account_id: { type: string }
        payload:
          type: object
          additionalProperties: true
          description: "Provider event detail."
        occurred_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    TestRecipient:
      type: object
      properties:
        id: { type: string, example: "tr_a1b2c3" }
        email: { type: string, format: email }
        status: { type: string, enum: [pending, verified] }
        verified_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Upload:
      type: object
      properties:
        id: { type: string, example: "asset_a1b2c3" }
        url: { type: string, format: uri, example: "https://api.sendara.dev/v1/assets/asset_a1b2c3" }
        content_type: { type: string, example: "image/png" }
        bytes: { type: integer }

    SpendCap:
      type: object
      description: |
        A spend cap. `key_id` is null for the
        account-wide cap. Limits are micro-dollars (1,000,000 = $1.00) and null
        when unset.
      properties:
        id: { type: string, example: "cap_a1b2c3" }
        account_id: { type: string }
        key_id: { type: [string, "null"] }
        soft_limit_micros: { type: [integer, "null"] }
        hard_limit_micros: { type: [integer, "null"] }

    EmailValidation:
      type: object
      required: [email, valid, reason]
      properties:
        email: { type: string, format: email }
        valid: { type: boolean }
        reason:
          type: string
          description: "Empty when valid. Otherwise why the address failed (e.g. no MX)."

    AccountVerification:
      type: object
      properties:
        account_id: { type: string }
        sandbox_mode:
          type: boolean
          description: |
            True when the account has no fully-verified sending domain, so email
            can only be sent from the shared platform sender to the account's own
            address or its verified test recipients.
        shared_email_sender:
          type: string
          description: The platform's shared From address used in sandbox mode.
        channels:
          type: array
          items: { $ref: "#/components/schemas/ChannelVerificationStatus" }

    ChannelVerificationStatus:
      type: object
      properties:
        channel: { type: string, example: "email" }
        status:
          type: string
          description: "Per-channel status, e.g. verified, pending, sandbox, registered, ready, or not_configured."
        verified: { type: boolean }
        details: { type: string }

    DnsProvider:
      type: object
      description: The detected DNS provider and provider-tailored setup hints.
      properties:
        id: { type: string }
        name: { type: string }
        docs_url: { type: string }
        dashboard_url: { type: string }
        supports_api: { type: boolean }
        unproxy_hint: { type: boolean }
        relative_host:
          type: boolean
          description: True when the provider's UI expects the record host relative to the zone apex.
        record_name_label: { type: string }
        record_value_label: { type: string }
        nameservers: { type: array, items: { type: string } }

    DnsSetupRecord:
      type: object
      properties:
        type: { type: string, example: "CNAME" }
        name: { type: string, description: "Fully-qualified record name." }
        host: { type: string, description: "Host relative to the zone apex, for providers that expect it." }
        value: { type: string }

    DnsSetup:
      type: object
      properties:
        domain: { type: string }
        zone_apex: { type: string }
        provider: { $ref: "#/components/schemas/DnsProvider" }
        records:
          type: array
          items: { $ref: "#/components/schemas/DnsSetupRecord" }
        mail_from_domain: { type: string }
        dkim_status: { $ref: "#/components/schemas/VerificationStatus" }
        spf_status: { $ref: "#/components/schemas/VerificationStatus" }
        dmarc_status: { $ref: "#/components/schemas/VerificationStatus" }
        txt_status: { $ref: "#/components/schemas/VerificationStatus" }

    SnippetInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        body_html: { type: string }
        body_text: { type: string }

    Snippet:
      type: object
      properties:
        id: { type: string }
        account_id: { type: string }
        name: { type: string }
        body_html: { type: string }
        body_text: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    GalleryTemplate:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        category: { type: string }
        description: { type: string }
        subject: { type: string }
        body_html: { type: string }
        body_text: { type: string }
        sample_variables:
          type: object
          additionalProperties: true

    InboundAttachment:
      type: object
      properties:
        filename: { type: string }
        content_type: { type: string }
        size: { type: integer }
        s3_key:
          type: string
          description: Internal storage key. Download the bytes via the attachment endpoint.

    InboundEmail:
      type: object
      description: |
        A received, parsed inbound email. List responses omit `text` and `html`
        to keep the payload light; fetch a single message for the full body.
      properties:
        id: { type: string }
        account_id: { type: string }
        domain: { type: string }
        recipient: { type: string }
        inbox_id: { type: string }
        from: { type: string }
        to: { type: array, items: { type: string } }
        cc: { type: array, items: { type: string } }
        subject: { type: string }
        text: { type: string }
        html: { type: string }
        headers:
          type: object
          additionalProperties: { type: string }
        spam_verdict: { type: string }
        virus_verdict: { type: string }
        spf_verdict: { type: string }
        dkim_verdict: { type: string }
        dmarc_verdict: { type: string }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/InboundAttachment" }
        size_bytes: { type: integer, format: int64 }
        received_at: { type: string, format: date-time }
        rfc_message_id: { type: string }
        in_reply_to: { type: string }
        references_ids: { type: array, items: { type: string } }
        normalized_subject: { type: string }
        thread_id: { type: string }

    InboundRoute:
      type: object
      properties:
        id: { type: string }
        domain: { type: string }
        match_prefix:
          type: string
          description: 'Local-part matched by this route, or "*" for the domain catch-all.'
        destination_type: { type: string, enum: [webhook, email] }
        forward_url:
          type: string
          description: HTTPS endpoint that receives signed POSTs for `webhook` routes.
        forward_address:
          type: string
          description: Destination inbox for `email` routes.
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        signing_secret:
          type: string
          description: HMAC secret signing forwarded webhook POSTs. Returned only once, on create.

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, status]
          properties:
            code:
              type: string
              description: |
                Stable machine-readable code. Treat this as an open set and
                branch only on codes you understand. Common values include:
                `unauthorized` (401), `forbidden` (403), `invalid_request` (400),
                `not_found` (404), `recipient_suppressed` (409),
                `idempotency_key_reused` (409), `from_required` (422),
                `from_not_verified` (422),
                `missing_variable` (400), `invalid_template` (400),
                `invalid_token` (400), `invalid_signature` (403),
                `rate_limit_exceeded` (429), `spend_cap_exceeded` (402),
                `invalid_schedule` (422),
                `billing_not_configured` (503), `duplicate_contact` (409),
                `duplicate_member` (409), `too_many_test_recipients` (422),
                `recipient_not_verified` (403), `test_send_daily_limit` (429),
                `payload_too_large` (413), `batch_too_large` (413), `invalid_email` (400),
                `invalid_list` (400), `contact_limit_reached` (402),
                `validation_not_configured` (503), `internal_error` (500).
              example: "from_not_verified"
            message: { type: string }
            status: { type: integer, example: 422 }
