openapi: 3.0.3

# Собрано из фактических контрактов api/v1/*.php (не из плана): index.php (роутинг,
# auth, rate-limit, idempotency), send.php, batch.php, templates.php, messages.php,
# suppressions.php, unsubscribe.php, lib_send.php, include/verify_lib.php.
# Версия OpenAPI — 3.0.3 ради максимальной совместимости с генераторами SDK.

info:
  title: MailerMail Email API
  version: "1.0"
  description: |
    Транзакционный и массовый email + верификация адресов. Контракт построен на
    привычной модели транзакционных Email API (from/to/subject/html/text/
    attachments), поэтому миграция с типового провайдера сводится к смене хоста
    и токена.

    ## Аутентификация
    Все эндпоинты, кроме `GET /health` и `/unsubscribe`, требуют токен — в заголовке
    `Api-Token: mm_live_…` **или** `Authorization: Bearer mm_live_…`. Токены с
    префиксом `mm_test_` — **sandbox**: `send`/`batch` проходят весь конвейер
    (валидация, suppression-check, лог), но письма физически не уходят.

    Токен несёт **scope**: `verify` (для `/verify`, `/verify/batch`) или `send`
    (для остальных). Нет нужного scope → `403`.

    ## Лимиты
    - **Rate limit:** 120 запросов/мин на токен. Превышение → `429` + `Retry-After`.
    - `POST /send`: ≤50 получателей, вложения ≤10 МБ суммарно, тело запроса ≤15 МБ.
    - `POST /batch`: ≤500 request'ов, ≤1000 писем суммарно, вложения не поддержаны.
    - `POST /verify/batch`: ≤100 адресов.

    ## Идемпотентность
    `POST /send` и `POST /batch` принимают заголовок `Idempotency-Key` (≤200 символов).
    Повтор с тем же ключом в окне 24 ч возвращает первый сохранённый ответ
    (`Idempotency-Replayed: true`); параллельный дубль → `409`. Ответы `5xx` не
    кэшируются — ретрай с тем же ключом реально повторяется.

servers:
  - url: https://mailermail.ru/api/v1
    description: Production

security:
  - ApiTokenAuth: []
  - BearerAuth: []

tags:
  - name: Health
  - name: Verification
  - name: Send
  - name: Batch
  - name: Templates
  - name: Suppressions
  - name: Messages
  - name: Unsubscribe

paths:

  /health:
    get:
      operationId: health
      tags: [Health]
      summary: Проверка живости (без auth)
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  ts: { type: string, format: date-time }

  /verify:
    post:
      operationId: verifyEmail
      tags: [Verification]
      summary: Проверить один адрес
      description: "Scope: `verify`. Без похода на SMTP: синтаксис, MX/A домена (кэш), ролевые, disposable, глобальный реестр недоставляемых, опечатки домена."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, example: "user@example.com" }
      responses:
        "200":
          description: Результат верификации
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      success: { type: boolean, example: true }
                  - $ref: "#/components/schemas/VerifyResult"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /verify/batch:
    post:
      operationId: verifyBatch
      tags: [Verification]
      summary: Проверить пачку адресов (≤100)
      description: "Scope: `verify`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string, format: email }
      responses:
        "200":
          description: Результаты + сводка по статусам
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  summary:
                    type: object
                    properties:
                      deliverable: { type: integer }
                      risky: { type: integer }
                      undeliverable: { type: integer }
                      unknown: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/VerifyResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /send:
    post:
      operationId: sendEmail
      tags: [Send]
      summary: Транзакционное письмо (синхронно, ≤50 получателей)
      description: |
        Scope: `send`. Отправка per-recipient (у каждого свой Message-ID) через
        SMTP-ферму с DKIM. **Домен отправителя обязан быть verified** в аккаунте
        (иначе `422`). `template_uuid` взаимоисключим с `subject`/`html`/`text` —
        их даёт шаблон. Персонализация `{{var}}` берётся из `template_variables`
        и `to[].substitutions` (per-recipient перекрывает глобальные).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendRequest" }
      responses:
        "200":
          description: Результат отправки (частичные ошибки — в skipped/failed)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Полный провал отправки (инфраструктура) — ретрай уместен
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendResponse" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /batch:
    post:
      operationId: sendBatch
      tags: [Batch]
      summary: Массовая отправка через очередь (≤500 request'ов)
      description: |
        Scope: `send`. Валидные письма встают в очередь (message_ids выдаются
        сразу), реально отправляет фоновый воркер — латентность до ~5 минут.
        `base` задаёт общие поля, поля каждого `request` их перекрывают. Вложения
        не поддержаны — используйте `/send`. HTTP всегда `200` (кроме структурных
        ошибок тела): пер-письмо статусы в `responses[]`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BatchRequest" }
      responses:
        "200":
          description: Пер-request результаты (в порядке requests)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: Список шаблонов
      description: "Scope: `send`."
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  total: { type: integer }
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/TemplateSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/Unavailable" }
    post:
      operationId: createTemplate
      tags: [Templates]
      summary: Создать шаблон
      description: "Scope: `send`. Нужно `name` и хотя бы одно из `html`/`text`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateCreate" }
      responses:
        "201":
          description: Создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  template: { $ref: "#/components/schemas/TemplateSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /templates/{uuid}:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Получить шаблон (с html/text)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  template: { $ref: "#/components/schemas/TemplateFull" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateTemplate
      tags: [Templates]
      summary: Обновить шаблон (частично)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplatePatch" }
      responses:
        "200": { $ref: "#/components/responses/SimpleOk" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteTemplate
      tags: [Templates]
      summary: Удалить шаблон
      responses:
        "200": { $ref: "#/components/responses/SimpleOk" }
        "404": { $ref: "#/components/responses/NotFound" }

  /suppressions:
    get:
      operationId: listSuppressions
      tags: [Suppressions]
      summary: Список стоп-листа или проверка одного адреса
      description: "Scope: `send`. С `?email=` — точечная проверка; без него — список."
      parameters:
        - { name: email, in: query, schema: { type: string, format: email }, description: "Если задан — вернётся статус одного адреса" }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: reason, in: query, schema: { $ref: "#/components/schemas/SuppressionReason" } }
      responses:
        "200":
          description: Список или результат точечной проверки
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      email: { type: string }
                      suppressed: { type: boolean }
                      reason: { type: string, nullable: true }
                      created_at: { type: string, nullable: true }
                  - type: object
                    properties:
                      success: { type: boolean }
                      total: { type: integer }
                      suppressions:
                        type: array
                        items: { $ref: "#/components/schemas/SuppressionItem" }
        "422": { $ref: "#/components/responses/ValidationError" }
    post:
      operationId: addSuppressions
      tags: [Suppressions]
      summary: Добавить адреса в стоп-лист (≤1000)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails: { type: array, minItems: 1, maxItems: 1000, items: { type: string, format: email } }
                reason: { $ref: "#/components/schemas/SuppressionReason" }
      responses:
        "200":
          description: Добавлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  added: { type: integer }
                  reason: { type: string }
                  invalid: { type: array, items: { type: string } }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: removeSuppressions
      tags: [Suppressions]
      summary: Убрать адреса из стоп-листа (снять блокировку, ≤1000)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails: { type: array, minItems: 1, maxItems: 1000, items: { type: string, format: email } }
      responses:
        "200":
          description: Удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  removed: { type: integer }
                  invalid: { type: array, items: { type: string } }
        "422": { $ref: "#/components/responses/ValidationError" }

  /messages:
    get:
      operationId: listMessages
      tags: [Messages]
      summary: Лог отправок (TTL 30 дней, новые первыми)
      description: "Scope: `send`."
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/MessageStatus" } }
        - { name: category, in: query, schema: { type: string } }
        - { name: email, in: query, schema: { type: string, format: email }, description: "Фильтр по получателю" }
        - { name: from, in: query, schema: { type: string }, description: "YYYY-MM-DD или YYYY-MM-DD HH:MM:SS" }
        - { name: to, in: query, schema: { type: string }, description: "YYYY-MM-DD (день включительно) или YYYY-MM-DD HH:MM:SS" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  total: { type: integer }
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/MessageItem" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /messages/{uuid}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Одно сообщение (+ состояние в очереди, если шло через batch)
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { $ref: "#/components/schemas/MessageItem" }
                  queue: { $ref: "#/components/schemas/QueueState" }
        "404": { $ref: "#/components/responses/NotFound" }

  /stats:
    get:
      operationId: getStats
      tags: [Messages]
      summary: Агрегаты по отправкам за период
      description: "Scope: `send`."
      parameters:
        - { name: days, in: query, schema: { type: integer, enum: [7, 30], default: 30 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  period_days: { type: integer }
                  total: { type: integer }
                  by_status:
                    type: object
                    additionalProperties: { type: integer }
                  delivery_rate: { type: number, nullable: true, description: "% sent от total" }
                  daily:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string }
                        total: { type: integer }
                        sent: { type: integer }
                        failed: { type: integer }
                        skipped: { type: integer }
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        category: { type: string }
                        count: { type: integer }
                  usage:
                    type: object
                    properties:
                      verifies: { type: integer }
                      sends: { type: integer }

  /unsubscribe:
    get:
      operationId: unsubscribePage
      tags: [Unsubscribe]
      summary: Страница отписки для человека (без auth)
      description: |
        Публичный endpoint (RFC 8058). Доступ санкционирует HMAC-подпись `s`, а не
        токен. GET **не меняет** состояние (по ссылкам ходят сканеры почтовых
        шлюзов) — отдаёт HTML с кнопкой, которая шлёт POST.
      security: []
      parameters:
        - { name: t, in: query, required: true, schema: { type: integer }, description: "tenant id" }
        - { name: e, in: query, required: true, schema: { type: string, format: email } }
        - { name: s, in: query, required: true, schema: { type: string }, description: "HMAC-подпись" }
      responses:
        "200":
          description: HTML-страница подтверждения
          content:
            text/html: { schema: { type: string } }
    post:
      operationId: unsubscribeOneClick
      tags: [Unsubscribe]
      summary: One-Click отписка (RFC 8058, без auth)
      description: "Вызывают почтовики (`List-Unsubscribe-Post`). Меняет состояние: адрес → стоп-лист + `contacts.StateID=2`."
      security: []
      parameters:
        - { name: t, in: query, required: true, schema: { type: integer } }
        - { name: e, in: query, required: true, schema: { type: string, format: email } }
        - { name: s, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Отписан
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  unsubscribed: { type: boolean }
        "400":
          description: Ссылка недействительна (подпись не сошлась)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /u/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{16}$" } }
    get:
      operationId: unsubscribeShortPage
      tags: [Unsubscribe]
      summary: Короткая ссылка отписки — страница (без auth)
      description: "Разворачивается в `/unsubscribe` по записи `api_unsub_links`. Ставится в заголовок `List-Unsubscribe`."
      security: []
      responses:
        "200":
          description: HTML-страница подтверждения
          content:
            text/html: { schema: { type: string } }
    post:
      operationId: unsubscribeShortOneClick
      tags: [Unsubscribe]
      summary: Короткая ссылка отписки — One-Click (без auth)
      security: []
      responses:
        "200":
          description: Отписан
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  unsubscribed: { type: boolean }
        "400":
          description: Ссылка недействительна
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:

  securitySchemes:
    ApiTokenAuth:
      type: apiKey
      in: header
      name: Api-Token
      description: "mm_live_… (боевой) или mm_test_… (sandbox)"
    BearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer mm_live_…"

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 200 }
      description: "Повтор с тем же ключом (окно 24 ч) возвращает первый ответ; параллельный дубль → 409."

  schemas:

    Error:
      type: object
      properties:
        success: { type: boolean, example: false }
        errors:
          type: array
          items: { type: string }
      required: [success, errors]

    Address:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        name: { type: string, maxLength: 128 }

    ToAddress:
      allOf:
        - $ref: "#/components/schemas/Address"
        - type: object
          properties:
            substitutions:
              type: object
              description: "Переменные {{var}} только для этого получателя (перекрывают template_variables). Скаляры; значение ≤64 КБ, суммарно ≤256 КБ."
              additionalProperties: true

    Attachment:
      type: object
      required: [content, filename]
      properties:
        content: { type: string, format: byte, description: "base64" }
        filename: { type: string, maxLength: 120 }
        type: { type: string, description: "MIME (token/token); иначе application/octet-stream", example: "application/pdf" }
        disposition: { type: string, enum: [attachment, inline], default: attachment }
        content_id: { type: string, description: "Обязателен при disposition=inline; в теле <img src=\"cid:…\">" }

    SendOptions:
      type: object
      properties:
        list_unsubscribe: { type: boolean, description: "Добавить заголовки List-Unsubscribe (One-Click)" }
        unsubscribe_url: { type: string, format: uri, description: "Свой https-URL отписки (включает list_unsubscribe)" }

    SendRequest:
      type: object
      required: [from, to]
      description: "subject/html/text ИЛИ template_uuid (взаимоисключимо). Нужно text и/или html."
      properties:
        from: { $ref: "#/components/schemas/Address" }
        to:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: "#/components/schemas/ToAddress" }
        reply_to: { $ref: "#/components/schemas/Address" }
        subject: { type: string, maxLength: 255 }
        html: { type: string }
        text: { type: string }
        category: { type: string, maxLength: 255 }
        template_uuid: { type: string, format: uuid }
        template_variables:
          type: object
          description: "Глобальные переменные {{var}} (скаляры)"
          additionalProperties: true
        attachments:
          type: array
          items: { $ref: "#/components/schemas/Attachment" }
        options: { $ref: "#/components/schemas/SendOptions" }

    SendResponse:
      type: object
      properties:
        success: { type: boolean }
        message_ids:
          type: array
          items: { type: string, format: uuid }
        sandbox: { type: boolean, description: "Присутствует и true для mm_test_ токенов" }
        skipped:
          type: array
          items: { $ref: "#/components/schemas/RecipientOutcome" }
        failed:
          type: array
          items: { $ref: "#/components/schemas/RecipientOutcome" }

    RecipientOutcome:
      type: object
      properties:
        email: { type: string }
        reason: { type: string, description: "skipped: suppressed|known_bounce; failed: текст ошибки/таймаут" }

    BatchRequest:
      type: object
      required: [requests]
      properties:
        base:
          type: object
          description: "Общие поля письма (как в SendRequest, без attachments); перекрываются полями request."
        requests:
          type: array
          minItems: 1
          maxItems: 500
          description: "Каждый — как SendRequest без attachments. Суммарно ≤1000 писем (request × получатели)."
          items:
            type: object
        options:
          type: object
          properties:
            send_at:
              type: string
              description: "Отложить отправку: 'YYYY-MM-DD HH:MM:SS' UTC, окно ≤72 ч (только batch)"
            skip_unsubscribe:
              type: boolean
              description: "Отключить List-Unsubscribe (в bulk он включён по умолчанию)"

    BatchResponse:
      type: object
      properties:
        success: { type: boolean }
        responses:
          type: array
          description: "Пер-request результат в порядке requests"
          items:
            type: object
            properties:
              success: { type: boolean }
              message_ids: { type: array, items: { type: string, format: uuid } }
              skipped: { type: array, items: { $ref: "#/components/schemas/RecipientOutcome" } }
              failed: { type: array, items: { $ref: "#/components/schemas/RecipientOutcome" } }
              errors: { type: array, items: { type: string }, description: "Присутствует при success=false (структурная ошибка request)" }
        scheduled_at: { type: string, description: "Присутствует при options.send_at (UTC)" }
        sandbox: { type: boolean }

    VerifyResult:
      type: object
      properties:
        email: { type: string }
        status:
          type: string
          enum: [deliverable, risky, undeliverable, unknown]
        checks:
          type: object
          properties:
            syntax: { type: boolean }
            domain: { type: string, nullable: true, enum: [ok, dead, unknown, null] }
            role: { type: boolean, description: "info@, support@ и т.п." }
            disposable: { type: boolean }
            known_bounce: { type: boolean }
            did_you_mean: { type: string, nullable: true, description: "Предложение при опечатке домена" }

    TemplateCreate:
      type: object
      required: [name]
      description: "Нужно хотя бы одно из html/text."
      properties:
        name: { type: string, maxLength: 128 }
        subject: { type: string, maxLength: 255 }
        html: { type: string, maxLength: 2097152 }
        text: { type: string, maxLength: 2097152 }
        category: { type: string, maxLength: 255 }

    TemplatePatch:
      type: object
      description: "Любое подмножество полей; после патча должно остаться html и/или text."
      properties:
        name: { type: string, maxLength: 128 }
        subject: { type: string, maxLength: 255 }
        html: { type: string, nullable: true, maxLength: 2097152 }
        text: { type: string, nullable: true, maxLength: 2097152 }
        category: { type: string, maxLength: 255 }

    TemplateSummary:
      type: object
      properties:
        uuid: { type: string, format: uuid }
        name: { type: string }
        subject: { type: string }
        category: { type: string }
        created_at: { type: string }
        updated_at: { type: string }

    TemplateFull:
      allOf:
        - $ref: "#/components/schemas/TemplateSummary"
        - type: object
          properties:
            html: { type: string }
            text: { type: string }

    SuppressionReason:
      type: string
      enum: [manual, hard_bounce, complaint, unsubscribed]

    SuppressionItem:
      type: object
      properties:
        email: { type: string }
        reason: { $ref: "#/components/schemas/SuppressionReason" }
        created_at: { type: string }

    MessageStatus:
      type: string
      enum: [sent, sandbox, skipped, failed, queued]

    MessageItem:
      type: object
      properties:
        message_id: { type: string, format: uuid }
        from: { type: string }
        to: { type: string }
        subject: { type: string }
        category: { type: string }
        status: { $ref: "#/components/schemas/MessageStatus" }
        reason: { type: string }
        sandbox: { type: boolean }
        created_at: { type: string }

    QueueState:
      type: object
      description: "Присутствует у писем, шедших через /batch"
      properties:
        status: { type: string }
        attempts: { type: integer }
        last_error: { type: string, nullable: true }
        sent_at: { type: string, nullable: true }
        next_try_at: { type: string, nullable: true }

  responses:
    SimpleOk:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, example: true }
    Unauthorized:
      description: Токен отсутствует, некорректен, неизвестен или отозван
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: У токена нет нужного scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Ресурс не найден
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Ошибка валидации тела/параметров
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: Тело/вложения превышают лимит
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Превышен лимит запросов (120/мин на токен)
      headers:
        Retry-After:
          schema: { type: integer }
          description: Секунд до сброса окна
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IdempotencyConflict:
      description: Запрос с этим Idempotency-Key уже выполняется или ключ использован для другого эндпоинта
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unavailable:
      description: БД недоступна или соответствующая часть API ещё не развёрнута (миграция не применена)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
