openapi: 3.1.0
info:
  title: ansible.to API
  description: |
    Email API for AI agents. Usage-based, no subscriptions.
    Agents get email addresses, send and receive messages, top up sends.
  version: 0.1.0
  contact:
    name: ansible.to
    url: https://ansible.to
servers:
  - url: https://api.ansible.to/v1
    description: Production
paths:
  /account:
    get:
      summary: Get account status
      description: Returns current account state — email, sends, trial status, creation time. The API key resolves the account, no ID needed.
      operationId: getAccount
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Account state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '404':
          description: Account not found or expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: account_not_found
                  message: account not found
    delete:
      summary: Close account
      description: Closes the account. All messages and routes are deleted. Irreversible.
      operationId: deleteAccount
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Account closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
        '404':
          description: Account not found or expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: account_not_found
                  message: account not found

  /send:
    post:
      summary: Send an email
      description: |
        The send endpoint. Always requires `Authorization: Bearer` header.

        **Session API key (first call)** — key is resolved against the `sessions` table.
        Atomically creates: account (trial, 10 sends), user, route, and a permanent API key.
        The permanent API key is returned in the response — shown exactly once.

        **Permanent API key (subsequent calls)** — validates key, sends email, debits 1 send.
      operationId: sendEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendEmailRequest'
            example:
              from: my-agent
              to: user@example.com
              subject: Sprint retro notes
              text: "Here's what we discussed..."
              html: "<p>Here's what we discussed...</p>"
      responses:
        '200':
          description: Email sent, sends deducted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResponse'
        '401':
          description: Invalid API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: invalid_api_key
                  message: invalid API key
        '402':
          description: |
            Payment required. Two cases:
            - Insufficient sends: `insufficient_sends` code
            - Trial mode restriction: `trial_mode` code
            Both include a `pay_url` field for the user to top up.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      pay_url:
                        type: string
                        format: uri
                        description: URL for the user to top up at https://ansible.to/pay/:accountId
              examples:
                insufficient_sends:
                  summary: Insufficient sends
                  value:
                    error:
                      code: insufficient_sends
                      message: insufficient sends remaining
                    pay_url: https://ansible.to/pay/acct_abc123
                trial_mode:
                  summary: Trial account restriction
                  value:
                    error:
                      code: trial_mode
                      message: trial accounts can only send to {owner_email}. pay to unlock sending to anyone
                    pay_url: https://ansible.to/pay/acct_abc123
        '404':
          description: Account not found or expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: account_not_found
                  message: account not found
        '409':
          description: Email address already taken by another account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: email_taken
                  message: email address already taken
        '429':
          description: Too many sends to this address (3/hr)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: rate_limited
                  message: too many sends to this address

  /inbox:
    get:
      summary: List received messages
      description: |
        Returns received messages newest-first.
      operationId: listInbox
      parameters:
        - name: unread
          in: query
          required: false
          schema:
            type: boolean
          description: Filter to unread messages only
        - name: n
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: Max results (default 20, max 100)
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: List of messages
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MessageSummary'

  /inbox/{id}:
    get:
      summary: Read a message
      description: Returns full message body and marks it as read.
      operationId: getMessage
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^msg_[a-zA-Z0-9]+$
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Full message
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
        '404':
          description: Message not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: message_not_found
                  message: message not found

  /inbox/{id}/reply:
    post:
      summary: Reply to a message
      description: |
        Reply to a received message. Automatically handles threading:
        - In-Reply-To ← original message's Message-ID
        - References ← appends to original References chain
        - Subject ← prepends Re: if not already present
        - To ← original From address
        - From ← the agent's address

        One send deducted per reply.
      operationId: replyToMessage
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: ^msg_[a-zA-Z0-9]+$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReplyRequest'
            example:
              text: Done. Deployed to production.
              html: "<p>Done. Deployed to production.</p>"
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Reply sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReplyResponse'
        '402':
          description: Insufficient sends
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      pay_url:
                        type: string
                        format: uri
                        description: URL for the user to top up at https://ansible.to/pay/:accountId
              examples:
                insufficient_sends:
                  summary: Insufficient sends
                  value:
                    error:
                      code: insufficient_sends
                      message: insufficient sends remaining
                    pay_url: https://ansible.to/pay/acct_abc123
                trial_mode:
                  summary: Trial account restriction
                  value:
                    error:
                      code: trial_mode
                      message: trial accounts can only send to {owner_email}. pay to unlock sending to anyone
                    pay_url: https://ansible.to/pay/acct_abc123
        '404':
          description: Message not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: message_not_found
                  message: message not found
        '409':
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: key_
      description: |
        API key for authentication. When you visit ansible.to, the docs
        include a pre-generated API key. Send your first email with that
        key and the response returns the permanent API key (key_<base58>).

  schemas:
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: Machine-readable error code (snake_case)
              example: insufficient_sends
            message:
              type: string
              description: Human-readable error message
              example: insufficient sends remaining
          description: Nested error object with code and message

    # ─── Account ───────────────────────────────────────────

    Account:
      type: object
      required:
        - id
        - email
        - sends
        - inbox_capacity
        - is_trial
        - created_at
      properties:
        id:
          type: string
          pattern: ^acct_[a-z0-9]+$
          example: acct_abc123
        email:
          type: string
          format: email
          description: The agent's email address @ansible.to
          example: my-agent@ansible.to
        sends:
          type: integer
          example: 10
        inbox_capacity:
          type: integer
          example: 100
        is_trial:
          type: boolean
          description: |
            Whether the account is in trial mode.
            Trial: can only send to account's user email, 24h lockout.
            Once topped up, is_trial becomes false (permanent).
          example: true
        created_at:
          type: string
          format: date-time
          description: Account creation time. Trial expiry is checked against this.
          example: "2026-04-26T10:00:00Z"

    # ─── Send / Messages ──────────────────────────────────

    SendEmailRequest:
      type: object
      required:
        - from
        - to
        - subject
      properties:
        from:
          type: string
          pattern: ^[a-z0-9][a-z0-9-]{0,31}$
          description: |
            Local-part for the sender's @ansible.to address.
            Creates a route if new.
          example: my-agent
        to:
          type: string
          format: email
          description: Recipient email address
          example: user@example.com
        cc:
          type: array
          items:
            type: string
            format: email
          description: CC recipients
          example:
            - alice@example.com
        bcc:
          type: array
          items:
            type: string
            format: email
          description: BCC recipients
        subject:
          type: string
          maxLength: 998
          description: Email subject
          example: Sprint retro notes
        text:
          type: string
          description: Plain text body
          example: "Here's what we discussed:\n- Feature A shipped\n- Bug B fixed"
        html:
          type: string
          description: HTML body
          example: "<p>Here's what we discussed:</p><ul><li>Feature A shipped</li><li>Bug B fixed</li></ul>"
        reply_to:
          type: string
          format: email
          description: Reply-To address (defaults to the agent's address)
          example: my-agent@ansible.to
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
          description: File attachments

    Attachment:
      type: object
      required:
        - filename
        - content
      properties:
        filename:
          type: string
          description: Display filename
          example: report.pdf
        content:
          type: string
          description: Base64-encoded file content
          example: JVBERi0xLjQK...
        content_type:
          type: string
          description: MIME type (defaults to application/octet-stream)
          example: application/pdf

    SendResponse:
      type: object
      required:
        - id
        - sends
      properties:
        id:
          type: string
          pattern: ^msg_[a-zA-Z0-9]+$
          description: Message ID
          example: msg_xyz789
        sends:
          type: integer
          description: Remaining sends after this send
          example: 9


    MessageSummary:
      type: object
      required:
        - id
        - from
        - subject
        - preview
        - ts
        - is_read
      properties:
        id:
          type: string
          example: msg_abc
        from:
          type: string
          format: email
          example: user@example.com
        subject:
          type: string
          example: "Re: Sprint retro notes"
        preview:
          type: string
          description: First 150 characters of the message body
          nullable: true
          example: "Looks good. Ship it."
        ts:
          type: string
          format: date-time
          description: ISO 8601 timestamp
          example: "2026-04-26T12:30:00Z"
        is_read:
          type: boolean
          example: false

    ReplyRequest:
      type: object
      required:
        - text
      properties:
        text:
          type: string
          description: Plain text body of the reply
          example: Done. Deployed to production.
        html:
          type: string
          description: HTML body of the reply (optional)
          example: "<p>Done. Deployed to production.</p>"

    ReplyResponse:
      type: object
      required:
        - id
        - sends
      properties:
        id:
          type: string
          pattern: ^msg_[a-zA-Z0-9]+$
          description: Message ID of the reply
          example: msg_xyz789
        sends:
          type: integer
          description: Remaining sends after this reply
          example: 9

    Message:
      type: object
      required:
        - id
        - from
        - to
        - subject
        - ts
        - is_read
      properties:
        id:
          type: string
          example: msg_abc
        from:
          type: string
          format: email
          example: user@example.com
        to:
          type: string
          format: email
          example: my-agent@ansible.to
        subject:
          type: string
          example: "Re: Sprint retro notes"
        text:
          type: string
          nullable: true
          description: Plain text body
          example: "Looks good. Ship it."
        html:
          type: string
          nullable: true
          description: HTML body
          example: "<p>Looks good. Ship it.</p>"
        ts:
          type: string
          format: date-time
          example: "2026-04-26T12:30:00Z"
        is_read:
          type: boolean
          example: false


