openapi: "3.1.0"
info:
  title: rail
  description: >
    Transactional email server with mTLS authentication.
    Outbound: clients submit via mTLS on port 465 or the HTTP API.
    Inbound: internet MTAs deliver to port 25, rail routes to webhooks.
  version: "1.0"
  contact:
    url: https://smtp.ataca.io

servers:
  - url: https://smtp.ataca.io
    description: Production

security: []

paths:
  /api/v1/send:
    post:
      operationId: sendEmail
      summary: Send an email
      description: >
        Queue an email for delivery. Requires mTLS client certificate.
        The sender address must match an email SAN in the client cert.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
      responses:
        "200":
          description: Message queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Sender not authorized by certificate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: >
            Unprocessable request — e.g. invalid address, too many recipients,
            or a recipient on this client's suppression list (error code
            "recipient_suppressed").
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/who:
    get:
      operationId: whoGet
      summary: Inspect your client certificate
      description: >
        Returns identity information from the mTLS client certificate
        presented with the request.
      responses:
        "200":
          description: Certificate details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WhoResponse"
    post:
      operationId: whoPost
      summary: Inspect any certificate
      description: >
        Upload a PEM-encoded certificate for inspection.
        No client certificate required. Anonymous requests receive
        certificate metadata and self-contained checks only; the valid and
        revoked verdicts are included only when the request itself presents
        an mTLS client certificate.
      requestBody:
        required: true
        content:
          application/x-pem-file:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Certificate details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WhoResponse"

  /api/v1/messages/{id}/deliveries:
    get:
      operationId: messageDeliveries
      summary: Get delivery status for a message
      description: >
        Returns every envelope recipient's delivery outcome for the given
        message ID. Per-recipient status is one of pending, delivered,
        bounced, or untracked (an envelope recipient with no delivery
        record — e.g. routed to a local domain, or predating per-recipient
        tracking); an untracked entry carries a note explaining why and
        attempts: 0. Requires mTLS.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Delivery status
          content:
            application/json:
              schema:
                type: object

  /api/v1/messages/{id}/opens:
    get:
      operationId: messageOpens
      summary: Get open-tracking events for a message
      description: >
        Returns open-tracking pixel events for the given message ID.
        Requires mTLS. Only available when tracking is enabled.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Open events
          content:
            application/json:
              schema:
                type: object

  /api/v1/forms:
    post:
      operationId: formCreate
      summary: Create a form target
      description: >
        Creates a form target owned by the authenticated client (mTLS). The
        from address must match an email SAN in the client cert — it becomes
        the DKIM-signed From of every submission. allowed_origins entries are
        normalized to their lowercase scheme://host origin; an entry may
        instead be a wildcard (https://*.example.com), matching the apex and
        any-depth subdomains. Only available when forms are enabled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FormCreateRequest"
      responses:
        "201":
          description: Form target created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FormResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: from address not permitted by client certificate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "413":
          description: Request body exceeds the maximum size
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: >
            Invalid address, origin, redirect URL, rate limit (0-10000), field
            length, or the per-client form quota is exhausted
            (error code "quota_exceeded")
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    get:
      operationId: formList
      summary: List your form targets
      description: Lists form targets owned by the authenticated client (mTLS).
      responses:
        "200":
          description: Form targets
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/FormResponse"

  /api/v1/forms/{token}:
    get:
      operationId: formGet
      summary: Get one form target
      description: >
        Returns the form target. A token owned by another client returns 404
        (not 403) so tokens cannot be probed.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Form target
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FormResponse"
        "404":
          description: Unknown token, or owned by another client
    patch:
      operationId: formUpdate
      summary: Partially update a form target
      description: >
        Updates the authenticated client's own form target. Every field is
        optional: an absent field keeps its current value, an explicit
        ""/[]/{} clears it. from, token, and the owning client are not part
        of this request body at all — any such key sent is ignored, since
        from is authorized against the cert's email SANs once at create time
        and the public /f/{token} endpoint has no cert to re-check it
        against later. Validation is the same rule set formCreate applies,
        run against the merged (stored + submitted) fields. A token owned by
        another client returns 404 (not 403) so tokens cannot be probed.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FormUpdateRequest"
      responses:
        "200":
          description: Updated form target
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FormResponse"
        "400":
          description: Invalid request, or a required field (name/subject/to) was cleared
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Unknown token, or owned by another client
        "413":
          description: Request body exceeds the maximum size
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: >
            Invalid address, origin, redirect URL, rate limit (0-10000), or
            field length
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: formDelete
      summary: Delete a form target
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Deleted
        "404":
          description: Unknown token, or owned by another client

  /api/v1/forms/{token}/submissions:
    get:
      operationId: formSubmissionsList
      summary: List a form's persisted submissions
      description: >
        Lists submissions for a form target owned by the authenticated client
        (mTLS), newest first. Only an ACCEPTED submission (one that produced
        an outbound email) is ever stored — honeypot, challenged, rate-limited,
        and all-recipients-suppressed submissions leave no row. A token owned
        by another client returns 404 (not 403) so tokens cannot be probed.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
        - name: before
          in: query
          description: Keyset cursor — the id of the last row from a previous page
          schema:
            type: string
        - name: limit
          in: query
          description: Page size, default 50, capped at 100
          schema:
            type: integer
      responses:
        "200":
          description: Submissions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FormSubmissionsResponse"
        "400":
          description: Unparsable limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Unknown token, or owned by another client

  /api/v1/forms/{token}/submissions/{id}:
    get:
      operationId: formSubmissionGet
      summary: Get one persisted submission
      description: >
        Returns one submission. 404 both when the id is unknown and when it
        belongs to a different form (even one owned by the same client).
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Submission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FormSubmissionResponse"
        "404":
          description: Unknown id, or belonging to a different form

  /f/{token}:
    post:
      operationId: formSubmit
      summary: Submit an HTML form
      description: >
        Public form-submission endpoint (no client certificate). Renders the
        submitted fields into an email for the form target identified by the
        token (created with `rail forms create`) and queues it for delivery.
        Only available when forms are enabled. Special underscore fields:
        _replyto (Reply-To), _subject (subject override, no line breaks),
        _redirect (origin-allowlisted redirect override), _gotcha (honeypot —
        leave empty). Browser form posts receive a 303 redirect; requests with
        "Accept: application/json" receive a JSON body instead.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties:
                type: string
          multipart/form-data:
            schema:
              type: object
              additionalProperties:
                type: string
      responses:
        "200":
          description: Submission accepted (JSON callers)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  id:
                    type: string
        "303":
          description: Submission accepted — redirect to the form's success URL
        "403":
          description: >
            Origin not permitted for this form — or, when captcha is
            configured and the submission is classified spam-likely, a JSON
            challenge for "Accept: application/json" callers:
            {"ok":false,"error":"challenge_required","challenge":{"provider":
            "turnstile","site_key":"...","nonce":"..."}}. Render the widget,
            then re-POST the same fields plus _challenge (the nonce) and
            cf-turnstile-response (the widget token). After a failed solve
            the response adds "reason": verify_failed (token rejected, retry)
            or verify_unavailable (verification outage, back off). Browser
            posts get a 200 HTML interstitial hosting the widget instead.
        "404":
          description: Unknown or disabled form token
        "413":
          description: Request body or rendered message exceeds the maximum size
        "415":
          description: Content-Type is not urlencoded or multipart form data
        "422":
          description: Invalid submission (e.g. line breaks in _subject, too many fields)
        "429":
          description: Form submission rate exceeded (Retry-After indicates the next free slot)

  /healthz:
    get:
      operationId: healthz
      summary: Liveness check
      responses:
        "200":
          description: Service is alive
          content:
            text/plain:
              schema:
                type: string
                example: ok

  /readyz:
    get:
      operationId: readyz
      summary: Readiness check
      responses:
        "200":
          description: Service is ready
          content:
            text/plain:
              schema:
                type: string
                example: ok
        "503":
          description: Service is draining

  /stats:
    get:
      operationId: stats
      summary: Relay statistics
      description: Returns uptime, queue depth, delivery counts, and latency percentiles.
      responses:
        "200":
          description: Statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Stats"

components:
  schemas:
    SendRequest:
      type: object
      required: [from, to, subject, body_text]
      properties:
        request_id:
          type: string
          description: Client-supplied idempotency key, echoed in response
        from:
          type: string
          format: email
          description: Sender address (must match a cert email SAN)
        to:
          type: array
          items:
            type: string
            format: email
          description: Recipient addresses
        cc:
          type: array
          items:
            type: string
            format: email
          description: CC recipients
        subject:
          type: string
        body_text:
          type: string
          description: Plain-text body. When body_html is omitted, rail auto-generates an HTML alternative from this text and sends multipart/alternative.
        body_html:
          type: string
          description: HTML body (optional, sent as multipart/alternative)
        reply_to:
          type: string
          format: email
          description: Override Reply-To (disables VERP tracking when set)
        unsubscribe_url:
          type: string
          format: uri
          description: Injects List-Unsubscribe and one-click unsubscribe headers (RFC 8058)

    SendResponse:
      type: object
      properties:
        id:
          type: string
          description: Message ID (ULID)
        request_id:
          type: string
          description: Echoed from request
        recipients:
          type: integer
          description: Number of recipients queued
        status:
          type: string
          enum: [queued]

    FormCreateRequest:
      type: object
      required: [name, from, to, subject]
      properties:
        name:
          type: string
          description: Human label, shown as the heading of submission emails
        from:
          type: string
          format: email
          description: Sender address (must match a cert email SAN)
        to:
          type: array
          items:
            type: string
            format: email
          description: Recipients of submission emails
        subject:
          type: string
          description: Default subject (submitters can override via _subject)
        redirect_url:
          type: string
          format: uri
          description: Post-submit redirect for browser form posts
        allowed_origins:
          type: array
          items:
            type: string
          description: >
            Origins allowed to submit: exact (https://example.com) or
            wildcard (https://*.example.com, matches the apex and any-depth
            subdomains). Normalized to lowercase; empty = any origin
        rate_limit:
          type: integer
          description: Submissions/hour for this form, 0-10000 (0 = server default)
        cc_submitter:
          type: boolean
          description: >
            CC the submitter's extracted email (the email/_replyto field) on
            each submission. Copies are silently skipped when the address is
            suppressed or over the global per-address rate limit.
        field_labels:
          type: object
          additionalProperties:
            type: string
          description: >
            Operator-only map of submitted field name to display label
            (max 64 entries, values <= 200 bytes), applied at render time.
            Unlisted fields auto-prettify (first_name -> First Name).

    FormUpdateRequest:
      type: object
      description: >
        Partial update body: every field is optional. An absent field keeps
        its current value; an explicit ""/[]/{} clears it. There is no from,
        token, or client_id field — those are immutable and any such key
        sent is ignored.
      properties:
        name:
          type: string
        to:
          type: array
          items:
            type: string
            format: email
        subject:
          type: string
        redirect_url:
          type: string
          format: uri
        allowed_origins:
          type: array
          items:
            type: string
          description: >
            Origins allowed to submit: exact (https://example.com) or
            wildcard (https://*.example.com, matches the apex and any-depth
            subdomains). Normalized to lowercase; empty = any origin
        rate_limit:
          type: integer
        cc_submitter:
          type: boolean
        field_labels:
          type: object
          additionalProperties:
            type: string
        enabled:
          type: boolean
          description: Kill switch, equivalent to `rail forms enable|disable`

    FormResponse:
      type: object
      properties:
        token:
          type: string
          description: Public form token — POST target is /f/{token}
        name:
          type: string
        from:
          type: string
        to:
          type: array
          items:
            type: string
        subject:
          type: string
        redirect_url:
          type: string
        allowed_origins:
          type: array
          items:
            type: string
        rate_limit:
          type: integer
        cc_submitter:
          type: boolean
        field_labels:
          type: object
          additionalProperties:
            type: string
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        cert_serial:
          type: string
          description: >
            Serial of the client certificate that authorized this form;
            submissions are refused once that certificate is revoked

    FormSubmissionResponse:
      type: object
      properties:
        id:
          type: string
        form_token:
          type: string
        form_name:
          type: string
          description: Snapshot of the form's name at submission time
        message_id:
          type: string
          description: The outbound message this submission produced
        fields:
          type: array
          description: Submitted (name, value) pairs, in submission order
          items:
            type: object
            properties:
              name:
                type: string
              value:
                type: string
        truncated:
          type: boolean
          description: >
            True when a field value was capped (total name+value bytes over
            16KiB); the field name is always kept whole
        subject:
          type: string
        reply_to:
          type: string
        source_ip:
          type: string
        user_agent:
          type: string
          description: Capped at 128 bytes
        origin:
          type: string
          description: The submitting request's Origin header, verbatim
        spam_score:
          type: integer
          nullable: true
          description: >
            Null when never classified (captcha off, or a solved challenge,
            which skips re-classification)
        spam_reasons:
          type: array
          items:
            type: string
        outcome:
          type: string
          enum: [clean, challenge_solved, unscored]
        created_at:
          type: string
          format: date-time

    FormSubmissionsResponse:
      type: object
      properties:
        submissions:
          type: array
          items:
            $ref: "#/components/schemas/FormSubmissionResponse"
        count:
          type: integer
        next_before:
          type: string
          description: Cursor for the next page; present only when this page was full

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Error code
        message:
          type: string
          description: Human-readable error message

    WhoResponse:
      type: object
      properties:
        client_id:
          type: string
        senders:
          type: array
          items:
            type: string
        webhooks:
          type: array
          items:
            type: string
        valid:
          type: boolean
        key_type:
          type: string
        not_before:
          type: string
          format: date-time
        not_after:
          type: string
          format: date-time

    Stats:
      type: object
      properties:
        uptime:
          type: string
        queue_depth:
          type: integer
        delivered:
          type: integer
        bounced:
          type: integer
        pending:
          type: integer
        rate:
          type: string
        version:
          type: string

    WebhookPayload:
      type: object
      description: >
        POSTed to client webhook URLs. type "inbound" (mail to a local domain)
        and "reply" (a VERP-tracked reply) carry the full message; type "bounce"
        notifies of a hard bounce of a previously sent message and type
        "complaint" notifies of a verified spam complaint against one, each
        carrying their own fields below instead of raw_message, with
        message_id set to a unique event id and original_message_id
        correlating back to the original outbound message.
      required: [type, message_id, received_at]
      properties:
        type:
          type: string
          enum: [inbound, reply, bounce, complaint]
        message_id:
          type: string
          description: >-
            The received message's ID (inbound/reply). For a bounce or
            complaint, a unique ID for the event (keeps webhook-id unique per
            recipient); correlate with original_message_id.
        in_reply_to:
          type: string
          description: Original message ID (reply type only)
        original_message_id:
          type: string
          description: The sent message that bounced or was complained about (bounce/complaint type only)
        bounced_recipient:
          type: string
          format: email
          description: The address that hard-bounced (bounce type only)
        bounce_status:
          type: string
          description: RFC 3463 status, e.g. "5.1.1" (bounce type only)
        bounce_diagnostic:
          type: string
          description: DSN Diagnostic-Code (bounce type only)
        bounce_type:
          type: string
          description: 'Always "hard" (bounce type only)'
        complained_recipient:
          type: string
          format: email
          description: The address that filed the complaint (complaint type only)
        feedback_type:
          type: string
          description: ARF Feedback-Type, e.g. "abuse" (complaint type only)
        complaint_source:
          type: string
          description: 'Always "arf" (complaint type only)'
        from:
          type: string
          format: email
        to:
          type: array
          items:
            type: string
            format: email
        subject:
          type: string
        raw_message:
          type: string
          format: byte
          description: Base64-encoded RFC 5322 message
        received_at:
          type: string
          format: date-time
        original_sender:
          type: string
          description: Reply type only
        original_recipients:
          type: array
          items:
            type: string
          description: Reply type only
