openapi: 3.1.0
info:
  title: Omnero Mail Developer API
  version: 1.0.0
  description: |
    Server-to-server API for tenant-scoped Omnero Mail integrations. API keys are
    service principals and never share dashboard or Webmail sessions.
servers:
  - url: http://localhost:3000
    description: Local development
security:
  - apiKey: []
tags:
  - name: Mailboxes
  - name: Domains
  - name: Messages
  - name: Attachments
  - name: Events
  - name: Webhooks
paths:
  /v1/mailboxes:
    get:
      operationId: listMailboxes
      tags: [Mailboxes]
      summary: List mailboxes available to the integration
      responses:
        '200':
          description: Mailbox collection
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Mailbox' }
        default: { $ref: '#/components/responses/Error' }
  /v1/domains:
    get:
      operationId: listDomains
      tags: [Domains]
      summary: List domains and delivery health
      responses:
        '200':
          description: Domain collection
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Domain' }
        default: { $ref: '#/components/responses/Error' }
  /v1/messages:
    get:
      operationId: listMessages
      tags: [Messages]
      summary: List messages using cursor pagination
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - in: query
          name: mailboxId
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Message page
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagePage'
        default: { $ref: '#/components/responses/Error' }
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Queue a message for delivery
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageInput' }
      responses:
        '202':
          description: Message queued
          headers:
            Idempotent-Replayed:
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueuedMessage' }
        '409': { $ref: '#/components/responses/Error' }
        default: { $ref: '#/components/responses/Error' }
  /v1/messages/{messageId}:
    parameters:
      - $ref: '#/components/parameters/MessageId'
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message and its MIME structure
      responses:
        '200':
          description: Message detail
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message: { type: object, additionalProperties: true }
        default: { $ref: '#/components/responses/Error' }
    patch:
      operationId: updateMessage
      tags: [Messages]
      summary: Update flags or folder
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateMessageInput' }
      responses:
        '200':
          description: Message updated
          content:
            application/json:
              schema:
                type: object
                required: [id, updated]
                properties:
                  id: { type: string, format: uuid }
                  updated: { type: boolean, const: true }
        default: { $ref: '#/components/responses/Error' }
  /v1/attachments:
    post:
      operationId: uploadAttachment
      tags: [Attachments]
      summary: Upload a small attachment using the legacy JSON transport
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadAttachmentInput' }
      responses:
        '201':
          description: Attachment created
          content:
            application/json:
              schema:
                type: object
                required: [attachment]
                properties:
                  attachment: { type: object, additionalProperties: true }
        default: { $ref: '#/components/responses/Error' }
  /v1/attachments/{attachmentId}/download:
    get:
      operationId: downloadAttachment
      tags: [Attachments]
      summary: Download a small attachment using the legacy JSON transport
      parameters:
        - in: path
          name: attachmentId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Base64 encoded attachment
          content:
            application/json:
              schema:
                type: object
                required: [filename, mediaType, contentBase64]
                properties:
                  filename: { type: string }
                  mediaType: { type: string }
                  contentBase64: { type: string, contentEncoding: base64 }
        default: { $ref: '#/components/responses/Error' }
  /v1/attachments/uploads:
    post:
      operationId: prepareAttachmentUpload
      tags: [Attachments]
      summary: Prepare a checksum-bound direct upload (attachments:write)
      description: Keep the same key for retries. Upload the bytes with PUT to the returned URL and headers, then complete. Upload expires after ten minutes. A completed retry returns attachment and no upload URL. Each file is at most 25 MiB; the complete MIME email, including base64 overhead, is also limited to 25 MiB.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PrepareAttachmentInput' }
      responses:
        '201':
          description: Prepared or already completed upload
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PreparedAttachment' }
        default: { $ref: '#/components/responses/Error' }
  /v1/attachments/uploads/{uploadId}/complete:
    post:
      operationId: completeAttachmentUpload
      tags: [Attachments]
      summary: Verify and finalize an uploaded object (attachments:write)
      parameters:
        - in: path
          name: uploadId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Attachment created; retries return the same attachment
          content:
            application/json:
              schema:
                type: object
                required: [attachment]
                properties:
                  attachment: { type: object, additionalProperties: true }
        '410': { description: Upload expired; start again with a new key }
        '422': { description: Size, media type or SHA-256 does not match }
        default: { $ref: '#/components/responses/Error' }
  /v1/attachments/{attachmentId}/download-url:
    get:
      operationId: getAttachmentDownloadUrl
      tags: [Attachments]
      summary: Obtain an authorized streaming download (attachments:read)
      description: The URL expires in sixty seconds. GET it without the API key. Each invocation checks current tenant, mailbox, credential and message visibility; expired or purged messages are unavailable even during legal preservation. An already started transfer is not recalled. Signed URLs are temporary secrets and must not be logged.
      parameters:
        - in: path
          name: attachmentId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Temporary streaming URL and expected checksum
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttachmentDownload' }
        default: { $ref: '#/components/responses/Error' }
  /v1/events:
    get:
      operationId: listEvents
      tags: [Events]
      summary: Reconcile events after webhook downtime
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/EventCursor'
      responses:
        '200':
          description: Event page
          content:
            application/json:
              schema:
                type: object
                required: [items, nextCursor]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Event' }
                  nextCursor:
                    oneOf:
                      - { type: string, description: Opaque event pagination cursor. }
                      - { type: 'null' }
        default: { $ref: '#/components/responses/Error' }
  /v1/webhook-endpoints:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhooks]
      summary: List endpoints belonging to the authenticated application
      responses:
        '200':
          description: Endpoint collection
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
        default: { $ref: '#/components/responses/Error' }
    post:
      operationId: createWebhookEndpoint
      tags: [Webhooks]
      summary: Subscribe the authenticated application to events
      description: >-
        Omnero Mail first POSTs a webhook.endpoint_verification payload to the URL.
        The endpoint must return HTTP 2xx JSON containing the exact challenge.
        Redirects are not followed and the request times out after five seconds.
        Nothing is persisted when verification fails. After success, the HMAC
        secret is returned once and signs operational deliveries.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, eventTypes]
              properties:
                url: { type: string, format: uri }
                description:
                  oneOf: [{ type: string, maxLength: 255 }, { type: 'null' }]
                eventTypes:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items: { type: string }
      responses:
        '201':
          description: The secret is returned once and must be stored securely
          content:
            application/json:
              schema:
                type: object
                required: [endpoint, secret]
                properties:
                  endpoint: { $ref: '#/components/schemas/WebhookEndpoint' }
                  secret: { type: string }
        '400':
          description: URL validation or endpoint verification handshake failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        default: { $ref: '#/components/responses/Error' }
  /v1/webhook-deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: endpointId
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Delivery collection
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { type: object, additionalProperties: true }
        default: { $ref: '#/components/responses/Error' }
  /v1/webhook-deliveries/{deliveryId}/replay:
    post:
      operationId: replayWebhookDelivery
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - in: path
          name: deliveryId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '202':
          description: Delivery returned to the pending queue; identical keys return the original result without resetting it
        default: { $ref: '#/components/responses/Error' }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: mdk_test_... or mdk_live_...
      description: Tenant-scoped API key shown only once when it is created.
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema: { type: string, minLength: 8, maxLength: 255 }
      description: Reuse for the same operation, target, and content for 24 hours. A conflicting request returns 409.
    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      in: query
      name: cursor
      schema: { type: string, format: date-time }
    EventCursor:
      in: query
      name: cursor
      description: Opaque event pagination cursor returned by a previous page. Legacy ISO 8601 timestamps are accepted for compatibility.
      schema: { type: string, minLength: 1, maxLength: 512 }
    MessageId:
      in: path
      name: messageId
      required: true
      schema: { type: string, format: uuid }
  responses:
    Error:
      description: Stable error envelope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Mailbox:
      type: object
      additionalProperties: false
      required: [id, address, displayName]
      properties:
        id: { type: string, format: uuid }
        address: { type: string, format: email }
        displayName: { type: string }
    Domain:
      type: object
      additionalProperties: false
      required: [id, domain, verified, health, mailboxCount]
      properties:
        id: { type: string, format: uuid }
        domain: { type: string }
        verified: { type: boolean }
        health: { type: string }
        mailboxCount: { type: integer, minimum: 0 }
    MessageSummary:
      type: object
      additionalProperties: true
      required: [id, createdAt]
      properties:
        id: { type: string, format: uuid }
        createdAt: { type: string, format: date-time }
    MessagePage:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/MessageSummary' }
        nextCursor:
          oneOf:
            - { type: string, format: date-time }
            - { type: 'null' }
    SendMessageInput:
      type: object
      additionalProperties: false
      required: [fromMailboxId, to]
      anyOf:
        - required: [text]
        - required: [html]
      properties:
        fromMailboxId: { type: string, format: uuid }
        to:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, format: email }
        cc:
          type: array
          maxItems: 50
          default: []
          items: { type: string, format: email }
        bcc:
          type: array
          maxItems: 50
          default: []
          items: { type: string, format: email }
        subject: { type: string, maxLength: 998, default: '' }
        text:
          oneOf: [{ type: string, maxLength: 1000000 }, { type: 'null' }]
        html:
          oneOf: [{ type: string, maxLength: 2000000 }, { type: 'null' }]
        attachmentIds:
          type: array
          maxItems: 20
          default: []
          items: { type: string, format: uuid }
    QueuedMessage:
      type: object
      additionalProperties: false
      required: [id, object, status]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: message }
        status: { type: string, const: queued }
    UpdateMessageInput:
      type: object
      additionalProperties: false
      required: [mailboxId]
      properties:
        mailboxId: { type: string, format: uuid }
        isRead: { type: boolean }
        isStarred: { type: boolean }
        folder: { type: string, enum: [inbox, sent, archive, spam, trash] }
    PrepareAttachmentInput:
      type: object
      additionalProperties: false
      required: [mailboxId, filename, mediaType, sizeBytes, sha256]
      properties:
        mailboxId: { type: string, format: uuid }
        filename: { type: string, minLength: 1, maxLength: 255 }
        mediaType: { type: string, maxLength: 255 }
        sizeBytes: { type: integer, minimum: 1, maximum: 26214400 }
        sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
    PreparedAttachment:
      type: object
      required: [uploadId, attachment, upload]
      properties:
        uploadId: { type: string, format: uuid }
        attachment: { type: [object, 'null'], additionalProperties: true }
        upload:
          type: [object, 'null']
          properties:
            url: { type: string, format: uri }
            method: { type: string, const: PUT }
            headers: { type: object, additionalProperties: { type: string } }
            expiresAt: { type: string, format: date-time }
    AttachmentDownload:
      type: object
      required: [url, expiresAt, filename, mediaType, sizeBytes, sha256]
      properties:
        url: { type: string, format: uri }
        expiresAt: { type: string, format: date-time }
        filename: { type: string }
        mediaType: { type: string }
        sizeBytes: { type: integer }
        sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
    UploadAttachmentInput:
      type: object
      additionalProperties: false
      required: [mailboxId, filename, mediaType, contentBase64]
      properties:
        mailboxId: { type: string, format: uuid }
        filename: { type: string, minLength: 1, maxLength: 255 }
        mediaType: { type: string, minLength: 1, maxLength: 120 }
        contentBase64: { type: string, contentEncoding: base64 }
    Event:
      type: object
      additionalProperties: false
      required: [id, type, resourceType, resourceId, data, occurredAt]
      properties:
        id: { type: string, format: uuid }
        type: { type: string }
        resourceType: { type: string }
        resourceId: { type: string, format: uuid }
        data: { type: object, additionalProperties: true }
        occurredAt: { type: string, format: date-time }
    WebhookEndpoint:
      type: object
      additionalProperties: false
      required: [id, appId, url, eventTypes, enabled, consecutiveFailures, disabledAt, createdAt]
      properties:
        id: { type: string, format: uuid }
        appId: { type: string, format: uuid }
        url: { type: string, format: uri }
        description:
          oneOf: [{ type: string }, { type: 'null' }]
        eventTypes:
          type: array
          items: { type: string }
        enabled: { type: boolean }
        consecutiveFailures: { type: integer, minimum: 0 }
        disabledAt:
          oneOf: [{ type: string, format: date-time }, { type: 'null' }]
        createdAt: { type: string, format: date-time }
    Error:
      type: object
      additionalProperties: false
      required: [code, message]
      properties:
        error: { type: string }
        code: { type: string }
        message: { type: string }
        requestId: { type: string }
