openapi: 3.1.0
info:
  title: MailHaap API
  version: "2026-08-01"
  description: |
    Transactional email, sending domains, and the accounts and keys behind
    them. All requests require TLS.

    Two credentials, and they are not interchangeable. An **API key**
    (`Authorization: Bearer mh_live_…` or `mh_test_…`) is for machines and
    carries scopes; a **session access token**, obtained from `/auth/login`,
    is for a person in the dashboard. Endpoints that manage the account itself
    — creating keys, revoking sessions — take only a session, because a key
    that can mint keys makes revoking the first one pointless.

    Test-mode keys accept identical requests and never deliver.

    **This document describes what the API serves today.** Operations planned
    but not built — batch sending, message retrieval, campaigns, audiences,
    template rendering, webhook management — are specified in `docs/05` and are
    deliberately absent here. A spec that promises an endpoint which answers
    404 is worse than one that stays quiet: a generated client compiles, ships,
    and fails at the customer.
  contact:
    name: MailHaap Support
    url: https://mailhaap.com/support
    email: support@mailhaap.com
  license:
    name: Proprietary
servers:
  - url: https://api.mailhaap.com/v1
    description: Production (EU)

security:
  - bearerAuth: []

tags:
  - name: Accounts
    description: Sign-up, sessions, and email confirmation. Session-authenticated.
  - name: API keys
    description: Issue and revoke keys. Session-authenticated; a key cannot mint a key.
  - name: Emails
  - name: Domains

paths:
  # ---------------------------------------------------------------- accounts
  /auth/signup:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: Create an account and its first workspace
      operationId: signup
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignupRequest' }
      responses:
        '201':
          description: Created; the session is returned and a verification email is sent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403':
          description: |
            Registration is not open. MailHaap requires an invite code until
            `make verify:providers` passes and posture moves to `live` —
            DEFERRALS.md row 20. The refusal carries `invite_required` and a
            waitlist URL.

            The same response is returned for a missing code and a wrong one:
            distinguishing them tells a caller their guess had the right shape,
            and there is nothing a legitimate person does differently on
            learning which it was.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /auth/login:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: Exchange credentials for a session
      operationId: login
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginRequest' }
      responses:
        '200':
          description: Signed in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429':
          description: |
            Too many sign-in attempts. Two limits apply: 30 per IP address and
            10 per account, each over 15 minutes, and a successful sign-in
            clears both. The per-account limit means a third party can lock a
            known address out for the window by failing against it — accepted
            deliberately, because without it a distributed guess is a matter of
            renting enough addresses.
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /auth/refresh:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: Exchange a refresh token for a new session
      operationId: refreshSession
      security: []
      description: |
        Refresh tokens rotate: the presented token is invalidated and a new one
        returned. Presenting a token that has already been rotated is treated
        as theft — the whole session family is revoked, not just that token —
        because the legitimate holder and the thief cannot be told apart, and
        ending the session is the only safe answer.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefreshRequest' }
      responses:
        '200':
          description: Rotated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: |
            Unknown, expired, or already-rotated refresh token. In the last
            case every session in the family has been revoked.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /auth/logout:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: End one session
      operationId: logout
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '204':
          description: |
            Ended. Returned for an unknown or already-expired token as well:
            whether a session exists is not something an unauthenticated
            caller gets to learn, and a 404 here would answer that question.

            No body. This document said 200 with `{"ended": true}` until a
            spec-driven fuzz run sent a logout and read what actually came
            back.
        '400':
          description: |
            No `refresh_token` in the body. Distinct from an unknown token,
            deliberately: a caller who forgot to send one has a bug, and
            answering 204 would tell them they are signed out while their
            refresh token stays live. An unknown token still gets 204.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /auth/me:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    get:
      tags: [Accounts]
      summary: The signed-in user
      operationId: getCurrentUser
      security:
        - sessionAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /auth/sessions/revoke-all:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: Sign out everywhere
      operationId: revokeAllSessions
      security:
        - sessionAuth: []
      responses:
        '200':
          description: |
            Every refresh token for this user is revoked immediately. Access
            tokens already issued keep working until they expire — up to 15
            minutes — because they are verified by signature and not looked up.
            The response states the moment after which none remain valid.
          content:
            application/json:
              schema:
                type: object
                required: [revoked, access_tokens_valid_until]
                properties:
                  revoked: { type: integer }
                  access_tokens_valid_until: { type: string, format: date-time }
                  note: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /auth/verify-email:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: Confirm an email address
      operationId: verifyEmail
      security: []
      description: |
        Unauthenticated, because the link is opened in whatever browser the
        person has to hand, which is usually not the one they signed up in.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200':
          description: Confirmed; live sending is unlocked for owned workspaces
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
                  email: { type: string, format: email }
                  note: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /auth/resend-verification:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Accounts]
      summary: Send the confirmation email again
      operationId: resendVerification
      security:
        - sessionAuth: []
      responses:
        '202':
          description: |
            Accepted. The same response is returned whether or not the address
            still needs confirming: an endpoint that distinguished them would
            report account state to anyone holding a session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: boolean }
                  note: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # -------------------------------------------------------------- api keys
  /api-keys:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    get:
      tags: [API keys]
      summary: List API keys
      operationId: listApiKeys
      security:
        - sessionAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKey' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [API keys]
      summary: Create an API key
      operationId: createApiKey
      security:
        - sessionAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateApiKeyRequest' }
      responses:
        '201':
          description: |
            Created. This is the only response that carries `key`, and there is
            no endpoint that can return it again — storage holds sha256 of the
            key and a display prefix, so there is nowhere to read it from.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedApiKey' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /api-keys/{id}:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    delete:
      tags: [API keys]
      summary: Revoke an API key
      operationId: revokeApiKey
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '204': { description: Revoked; the key stops working immediately }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # --------------------------------------------------------------- emails
  /emails:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Emails]
      summary: Send an email
      operationId: sendEmail
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendEmailRequest' }
      responses:
        '202':
          description: Accepted and queued
          headers:
            Idempotent-Replay:
              description: Present and "true" when this is a replay of an earlier request
              schema: { type: string, enum: ["true"] }
            X-RateLimit-Remaining: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessageAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '451': { $ref: '#/components/responses/ContentBlocked' }

  /emails/{id}/cancel:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Emails]
      summary: Cancel a scheduled or queued message
      operationId: cancelEmail
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [cancelled] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: |
            The message has left: once handed to a provider there is nothing
            left to cancel, and reporting otherwise would be a lie a customer
            acts on.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  # -------------------------------------------------------------- domains
  /domains:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    get:
      tags: [Domains]
      summary: List sending domains
      operationId: listDomains
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Domain' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Domains]
      summary: Add a sending domain
      operationId: createDomain
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, examples: ["abcemlak.com"] }
                purpose:
                  type: [array, "null"]
                  default: [sending]
                  description: |
                    Omitted or null means `["sending"]`. Both are accepted
                    because both mean "I did not choose", and refusing one of
                    them would be a distinction without a difference to
                    anybody sending JSON.
                  items: { $ref: '#/components/schemas/DomainPurpose' }
      responses:
        '201':
          description: Created, with the DNS records to publish
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /domains/{id}:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    get:
      tags: [Domains]
      summary: Get one sending domain
      operationId: getDomain
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Domains]
      summary: Change a domain's options
      operationId: updateDomain
      parameters:
        - $ref: '#/components/parameters/PathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                purpose:
                  type: array
                  items: { $ref: '#/components/schemas/DomainPurpose' }
                mail_from_subdomain: { type: string }
                tracking_host: { type: string }
      responses:
        '200':
          description: |
            Updated. `records_changed` is always true: changing any of these
            changes the record set, so the domain must be re-verified.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Domain'
                  - type: object
                    properties:
                      records_changed: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
    delete:
      tags: [Domains]
      summary: Remove a sending domain
      operationId: deleteDomain
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '204': { description: Removed }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /domains/{id}/dns:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    get:
      tags: [Domains]
      summary: The DNS records this domain needs, and what is published now
      operationId: getDomainDns
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { type: string }
                  status: { $ref: '#/components/schemas/DomainStatus' }
                  last_checked_at: { type: [string, "null"], format: date-time }
                  records:
                    type: array
                    items: { $ref: '#/components/schemas/DnsRecord' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /domains/{id}/verify:
    parameters:
      - $ref: '#/components/parameters/ApiVersion'
    post:
      tags: [Domains]
      summary: Check the published DNS now
      operationId: verifyDomain
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: |
            The check ran. A 200 does not mean verified — read `verified`.
            `propagating` distinguishes "you have not published it" from "you
            published it and the world has not caught up", which are the same
            symptom and different advice.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Domain'
                  - type: object
                    required: [verified, propagating]
                    properties:
                      verified: { type: boolean }
                      propagating: { type: boolean }
                      findings:
                        type: array
                        items: { $ref: '#/components/schemas/Finding' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }


components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        An API key: `mh_live_…` or `mh_test_…`. Carries scopes, belongs to a
        workspace, and is shown once at creation.
    sessionAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A session access token from `/auth/login` or `/auth/refresh`. Short
        lived — 15 minutes — and verified by signature rather than looked up,
        which is why signing out everywhere cannot revoke one already issued.

        Used for endpoints that manage the account itself. An API key is
        refused on those deliberately: a key that can mint keys makes revoking
        the first one pointless.

  parameters:
    ApiVersion:
      name: Mailhaap-Version
      in: header
      required: false
      schema: { type: string, enum: ["2026-08-01"] }
      description: |
        The dated API version. Omitted, a request follows 2026-08-01 — the
        current date, not "latest": if unpinned requests followed the newest
        date, every future behavioural change would change every unpinned
        integration at once.

        A date this API does not support is refused with `400
        unsupported_version` rather than served the nearest one. A caller that
        pinned a version has said it depends on that behaviour, and quietly
        substituting another is the failure pinning exists to prevent.

        Every response carries this header naming the version actually applied,
        error responses included.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 255 }
      description: Replays within 24 hours return the original response.
    PathId:
      name: id
      in: path
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: |
        Malformed request: `invalid_request`, `invalid_json`,
        `unsupported_content_type`, or `unsupported_version`.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    IdempotencyConflict:
      description: |
        The same `Idempotency-Key` was used for a different request body within
        24 hours. Returning the original response would be wrong — it answers a
        different question — and accepting this one would defeat the key.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ContentBlocked:
      description: Refused by the abuse filter (`content_blocked`)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Missing, invalid or revoked API key
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: Insufficient scope, IP not allowed, or tenant suspended
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Not found (also returned for resources belonging to another tenant)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnprocessableEntity:
      description: Validation or business rule failure
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: Too many requests
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        code: { type: string, examples: ["domain_not_verified"] }
        detail: { type: string }
        request_id: { type: string }
        docs_url: { type: string, format: uri }

    User:
      type: object
      required: [id, email]
      properties:
        id: { type: string, examples: ["usr_0a1b2c3d4e5f60718293a4b5c6d7e8f90"] }
        email: { type: string, format: email }
        name: { type: string }
        role: { type: string, enum: [owner, admin, member] }

    Workspace:
      type: object
      required: [id, name, role, status]
      properties:
        id: { type: string, examples: ["ws_0a1b2c3d4e5f60718293a4b5c6d7e8f90"] }
        name: { type: string }
        slug: { type: string }
        role: { type: string, enum: [owner, admin, member] }
        status: { type: string, enum: [active, suspended, closed] }

    Session:
      type: object
      required: [access_token, refresh_token, expires_at]
      properties:
        access_token:
          type: string
          description: |
            Bearer this on subsequent requests. Valid 15 minutes. Hold it in
            memory: the browser client keeps it out of storage entirely and the
            refresh token in an httpOnly cookie, so a cross-site script cannot
            read either.
        refresh_token:
          type: string
          description: |
            Single use. Exchanging it invalidates it and returns a new one.
        expires_at: { type: string, format: date-time }
        user: { $ref: '#/components/schemas/User' }
        workspace: { $ref: '#/components/schemas/Workspace' }
        workspaces:
          type: array
          items: { $ref: '#/components/schemas/Workspace' }

    SignupRequest:
      type: object
      required: [email, password, workspace_name]
      properties:
        invite_code:
          type: string
          description: |
            Required while registration is invite-only, which it is until
            posture moves to live. Presented in the body rather than a header
            so that a person pasting a curl example does not lose it.
        email: { type: string, format: email }
        password:
          type: string
          minLength: 12
          description: |
            Checked against the k-anonymity range API for known breaches and
            refused if found. Length is the only composition rule.
        name: { type: string }
        workspace_name: { type: string }
        locale: { type: string, examples: ["tr-TR"] }

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string }
        workspace_id:
          type: string
          description: |
            Which workspace to sign in to when the account belongs to several.
            Omitted, the response lists them and carries no workspace.

    RefreshRequest:
      type: object
      required: [refresh_token]
      properties:
        refresh_token: { type: string }
        workspace_id:
          type: string
          description: Switch workspace as part of the refresh.

    ApiKey:
      type: object
      required: [id, name, prefix, mode, scopes, created_at, revoked]
      properties:
        id: { type: string, examples: ["key_0a1b2c3d4e5f60718293a4b5c6d7e8f90"] }
        name: { type: string }
        prefix:
          type: string
          description: |
            The first characters of the key, kept for display. Storage holds
            sha256 of the key and this; the key itself is nowhere.
          examples: ["mh_live_a1b2c3"]
        mode: { type: string, enum: [live, test] }
        scopes:
          type: array
          items: { $ref: '#/components/schemas/Scope' }
        domain_id:
          type: string
          description: Present when the key may only send from one domain.
        ip_allowlist:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at:
          type: [string, "null"]
          format: date-time
          description: Null until the key is first used. Best-effort, written off the request path.
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Null when the key does not expire.
        revoked_at:
          type: [string, "null"]
          format: date-time
        revoked: { type: boolean }

    CreatedApiKey:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: |
                The only time this value exists in a response. There is no
                endpoint that returns it again, because there is nowhere to
                read it from.
            reveal: { type: string }

    CreateApiKeyRequest:
      type: object
      required: [name, scopes]
      properties:
        name: { type: string, maxLength: 128 }
        mode: { type: string, enum: [live, test], default: test }
        scopes:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Scope' }
        domain_id: { type: string }
        ip_allowlist:
          type: array
          items: { type: string, description: CIDR or single address }
        expires_at: { type: string, format: date-time }

    Scope:
      type: string
      enum:
        - emails:send
        - emails:read
        - domains:read
        - domains:write
        - contacts:write
        - campaigns:write
        - templates:read
        - webhooks:write
        - analytics:read

    DomainStatus:
      type: string
      enum: [pending, verified, failed, degraded, disabled]

    Finding:
      type: object
      description: One thing the verification check learned about one record.
      properties:
        kind: { type: string }
        status: { type: string, enum: [ok, missing, mismatch, propagating] }
        detail: { type: string }
        expected: { type: string }
        observed: { type: string }

    Attachment:
      type: object
      required: [filename]
      properties:
        filename: { type: string, maxLength: 255 }
        content: { type: string, contentEncoding: base64 }
        url: { type: string, format: uri }
        content_type: { type: string }
        content_id: { type: string, description: For inline images (cid:) }

    Tag:
      type: object
      required: [name, value]
      properties:
        name: { type: string, pattern: '^[A-Za-z0-9_-]{1,64}$' }
        value: { type: string, maxLength: 128 }

    SendEmailRequest:
      type: object
      required: [from, to]
      properties:
        from: { type: string, examples: ["ABC Emlak <info@abcemlak.com>"] }
        to:
          oneOf:
            - type: string
            - type: array
              items: { type: string }
              maxItems: 50
        cc: { type: array, items: { type: string } }
        bcc: { type: array, items: { type: string } }
        reply_to: { type: array, items: { type: string } }
        subject: { type: string, maxLength: 998 }
        html: { type: string }
        text: { type: string }
        template_id: { type: string }
        template_data: { type: object, additionalProperties: true }
        attachments:
          type: array
          maxItems: 20
          items: { $ref: '#/components/schemas/Attachment' }
        headers: { type: object, additionalProperties: { type: string } }
        tags:
          type: array
          items: { $ref: '#/components/schemas/Tag' }
        tracking:
          type: object
          properties:
            opens: { type: boolean, default: false }
            clicks: { type: boolean, default: true }
        scheduled_at: { type: string, format: date-time }

    MessageAccepted:
      type: object
      properties:
        id: { type: string, examples: ["msg_01J8XQ7M2K9PQRSTUV"] }
        status: { type: string, enum: [queued, scheduled] }
        created_at: { type: string, format: date-time }


    DnsRecord:
      type: object
      properties:
        kind:
          type: string
          enum: [ownership, dkim, spf, mail_from_mx, mail_from_spf, dmarc,
                 tracking, mx, autodiscover, tls_rpt, mta_sts]
        type: { type: string, enum: [TXT, CNAME, MX, A, AAAA, SRV] }
        host: { type: string }
        value: { type: string }
        priority: { type: integer }
        required: { type: boolean }
        status: { type: string, enum: [pending, ok, mismatch, missing] }
        observed_value: { type: string }
        note: { type: string }

    DomainPurpose:
      type: string
      description: |
        What this domain is for. `sending` covers transactional and campaign
        mail — they differ by plane, not by domain — and `mailbox` is hosted
        mail, which needs a different record set.
      enum: [sending, mailbox]

    Domain:
      type: object
      required: [id, name, status]
      properties:
        id: { type: string, examples: ["dom_0a1b2c3d4e5f60718293a4b5c6d7e8f9"] }
        name: { type: string, examples: ["abcemlak.com"] }
        status: { $ref: '#/components/schemas/DomainStatus' }
        purpose:
          type: array
          items: { $ref: '#/components/schemas/DomainPurpose' }
        mail_from_subdomain:
          type: string
          description: |
            The subdomain bounces return to. A custom MAIL FROM is what makes
            SPF align on a domain the customer controls rather than on ours.
        tracking_host:
          type: [string, "null"]
          description: The host that open and click links point at, when tracking is on.
        created_at: { type: string, format: date-time }
        verified_at:
          type: [string, "null"]
          format: date-time
          description: Null until the DNS check has passed at least once.
        last_checked_at:
          type: [string, "null"]
          format: date-time
          description: Null until the first verification attempt.
        records:
          type: array
          items: { $ref: '#/components/schemas/DnsRecord' }



