openapi: 3.0.3
info:
  title: Email Security Checker API
  version: 2.0.0
  description: |
    Public, read-only analysis of email DNS posture and pasted message headers.
    No authentication is currently required. Domain checks perform live public DNS
    and HTTPS observations; header analysis interprets receiver-reported evidence
    but does not cryptographically verify DKIM signatures. Scores are guidance,
    not proof of deliverability or safety. Domain and batch reports are stored only
    when the caller sets share: true (or the UI checkbox). Stored bearer links are
    retained for 14 days, served with private no-store caching, and can be revoked
    with DELETE /api/reports/{id}. Domain analysis caps outbound work at 45
    subrequests per invocation and exposes provider and uncertainty state in the
    response.
servers:
  - url: https://email.illek.ie
x-ai-usage:
  preferred-interface: REST or Streamable HTTP MCP
  mcp-endpoint: https://email.illek.ie/mcp/v2
  mcp-protocol-version: 2025-11-25
  mcp-tools: [analyze_email_domain, analyze_email_headers, analyze_email_domains_batch, inspect_spf, evaluate_spf, validate_email_record, build_email_record, enrich_email_hops, get_email_security_report]
  instructions:
    - Use domain-check for a bare public DNS domain, never a URL or email address.
    - Use header-analysis only with complete raw headers and omit the message body.
    - Treat pasted Authentication-Results as receiver-reported, not independently verified.
    - Never describe a high score as a deliverability or safety guarantee.
x-availability: Open for testing. Limited requests.
paths:
  /api/health:
    get:
      operationId: getEmailSecurityHealth
      summary: Return a cheap liveness response
      responses:
        '200':
          description: Service is responding
          content:
            application/json:
              schema:
                type: object
                required: [ok, service, version, source_revision, liveness]
                properties:
                  ok: { type: boolean }
                  service: { type: string }
                  version: { type: string }
                  source_revision: { type: string }
                  liveness: { type: boolean }
    head:
      operationId: probeEmailSecurityHealth
      summary: Probe liveness without a response body
      responses:
        '200': { description: Service is responding }
  /api:
    get:
      operationId: getEmailSecurityDirectory
      summary: List supported REST and MCP endpoints
      description: The trailing-slash variant /api/ and the /api/v2 alias answer identically.
      responses:
        '200': { description: Endpoint directory, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
  /api/v2:
    get:
      operationId: getEmailSecurityDirectoryV2
      summary: Alias of the endpoint directory at /api
      responses:
        '200': { description: Endpoint directory, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
  /api/check:
    post:
      operationId: analyzeEmailDomainLegacy
      summary: Legacy alias for domain analysis
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DomainRequest' }
      responses:
        '200': { description: Completed domain analysis, content: { application/json: { schema: { $ref: '#/components/schemas/DomainReport' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/v2/domain-check:
    post:
      operationId: analyzeEmailDomain
      summary: Analyze public email-security posture for a domain
      description: Checks SPF, common DKIM selector evidence, DMARC, MX, CAA, reverse DNS, MTA-STS, and TLS-RPT. Set share to true to persist a 14-day bearer report.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DomainRequest' }
            example: { domain: example.com }
      responses:
        '200':
          description: Completed domain analysis
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DomainReport' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/v2/header-analysis:
    post:
      operationId: analyzeEmailHeaders
      summary: Interpret received-message headers
      description: Parses delivery hops, receiver-reported SPF/DKIM/DMARC results, alignment, and contradictory evidence.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HeaderRequest' }
            example:
              headers: "From: Sender <sender@example.com>\r\nAuthentication-Results: mx.example; spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com; dmarc=pass header.from=example.com\r\n\r\n"
      responses:
        '200':
          description: Completed header interpretation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HeaderReport' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/header/analyze:
    post:
      operationId: analyzeEmailHeadersLegacy
      summary: Legacy alias of /api/v2/header-analysis
      description: Behaves identically to /api/v2/header-analysis; kept for early clients.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HeaderRequest' }
      responses:
        '200':
          description: Completed header interpretation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HeaderReport' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/batch:
    post:
      operationId: analyzeEmailDomainsBatch
      summary: Compare email-security posture across up to 3 domains
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [domains], properties: { domains: { type: array, minItems: 1, maxItems: 3, uniqueItems: true, items: { type: string, maxLength: 253 } }, share: { type: boolean, description: When true, persist a 14-day bearer batch report. Default false. } } }
            example: { domains: [example.com, example.org] }
      responses:
        '200': { description: Batch comparison report, content: { application/json: { schema: { $ref: '#/components/schemas/BatchReport' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/spf/inspect:
    post:
      operationId: inspectSpf
      summary: Recursively inspect a published SPF policy and flattening preview
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/DomainRequest' }, example: { domain: example.com } } }
      responses:
        '200': { description: SPF inspection, content: { application/json: { schema: { type: object, required: [domain, spf, flatten, request_budget], properties: { domain: { type: string }, spf: { type: object, additionalProperties: true }, flatten: { type: object, additionalProperties: true }, request_budget: { $ref: '#/components/schemas/RequestBudget' } } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/spf/evaluate:
    post:
      operationId: evaluateSpf
      summary: Evaluate SPF for a client IP and envelope sender
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/SpfEvaluationRequest' }, example: { ip: 192.0.2.1, sender: postmaster@example.com, helo: example.com } } }
      responses:
        '200': { description: RFC SPF evaluation result, content: { application/json: { schema: { type: object, required: [status, lookups, request_budget], additionalProperties: true, properties: { request_budget: { $ref: '#/components/schemas/RequestBudget' } } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/records/validate:
    post:
      operationId: validateEmailRecord
      summary: Validate a proposed SPF or DMARC TXT record
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/RecordValidationRequest' }, example: { type: spf, domain: example.com, record: 'v=spf1 -all' } } }
      responses:
        '200': { description: Validation errors, warnings, parsed evidence, and the request budget, content: { application/json: { schema: { $ref: '#/components/schemas/ValidationReport' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/v2/record-build:
    post:
      operationId: buildEmailRecord
      summary: Build and validate an SPF or DMARC record with rollout safeguards
      description: Returns a proposed DNS host and record, full validation, safety warnings, and publishReady. Never publish when publishReady is false.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecordBuildRequest' }
            example: { type: spf, domain: example.com, mechanisms: ['include:_spf.google.com'], policy: '~all', rolloutStage: testing }
      responses:
        '200': { description: Built and validated record, content: { application/json: { schema: { $ref: '#/components/schemas/RecordBuildReport' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/header/enrich:
    post:
      operationId: enrichEmailHops
      summary: Enrich up to 10 public delivery-hop IP addresses
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, additionalProperties: false, required: [ips], properties: { ips: { type: array, minItems: 1, maxItems: 10, uniqueItems: true, items: { type: string, maxLength: 45 } } } }, example: { ips: [8.8.8.8] } } }
      responses:
        '200': { description: Bounded PTR and registration enrichment, content: { application/json: { schema: { type: object, required: [enriched, limit, request_budget], properties: { enriched: { type: array, items: { type: object, additionalProperties: true } }, limit: { type: integer, enum: [10] }, request_budget: { $ref: '#/components/schemas/RequestBudget' } } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/BodyTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /api/reports/{reportId}:
    head:
      operationId: probeEmailSecurityReport
      summary: Probe an unexpired report without a response body
      description: Shares the GET contract, including per-minute and daily quota accounting; the runtime strips the body.
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}(?:[A-Za-z0-9_-]{16})?$' } }
      responses:
        '200': { description: The report exists and is unexpired }
        '404': { description: Report not found or expired }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { description: Report storage or usage accounting is temporarily unavailable; the report is not known to be gone }
    get:
      operationId: getEmailSecurityReport
      summary: Retrieve an unexpired domain or batch report
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}(?:[A-Za-z0-9_-]{16})?$' } }
      responses:
        '200':
          description: Stored domain or batch report. The response is a bearer-link resource and is private and not cacheable.
          headers:
            Cache-Control: { schema: { type: string, example: 'private, no-store' } }
            X-Report-Retention-Days: { $ref: '#/components/headers/XReportRetentionDays' }
          content: { application/json: { schema: { type: object, additionalProperties: true } } }
        '404': { description: Report not found or expired }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { description: Report storage or usage accounting is temporarily unavailable; the report is not known to be gone }
    delete:
      operationId: revokeEmailSecurityReport
      summary: Revoke a stored bearer report
      description: Deletes the unexpired domain or batch row identified by the bearer id. No login is required; possession of the id is the credential. Consumes the same per-minute and daily report quotas as GET.
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}(?:[A-Za-z0-9_-]{16})?$' } }
      responses:
        '200':
          description: The report was revoked
          content: { application/json: { schema: { type: object, required: [revoked, id], properties: { revoked: { type: boolean }, id: { type: string } } } } }
        '404': { description: Report not found or expired }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { description: Report storage or usage accounting is temporarily unavailable; the report is not known to be gone }
  /api/reports/{reportId}/export:
    get:
      operationId: exportEmailSecurityReport
      summary: Download an unexpired report as formatted JSON
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}(?:[A-Za-z0-9_-]{16})?$' } }
      responses:
        '200':
          description: JSON report attachment. The response is private and not cacheable.
          headers:
            Cache-Control: { schema: { type: string, example: 'private, no-store' } }
            X-Report-Retention-Days: { $ref: '#/components/headers/XReportRetentionDays' }
          content: { application/json: { schema: { type: object, additionalProperties: true } } }
        '404': { description: Report not found or expired }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { description: Report storage or usage accounting is temporarily unavailable; the report is not known to be gone }
    head:
      operationId: probeEmailSecurityReportExport
      summary: Probe export availability without a response body
      description: Shares the GET contract, including per-minute and daily quota accounting; the runtime strips the body.
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}(?:[A-Za-z0-9_-]{16})?$' } }
      responses:
        '200': { description: Export would succeed }
        '404': { description: Report not found or expired }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { description: Report storage or usage accounting is temporarily unavailable; the report is not known to be gone }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
  /mcp/v2:
    post:
      operationId: invokeEmailSecurityMcp
      summary: Invoke the stateless Streamable HTTP MCP endpoint
      description: |
        Send JSON-RPC 2.0 messages. Accept must include application/json and text/event-stream.
        Initialize negotiates 2025-11-25, 2025-06-18, or 2024-11-05. The unversioned /mcp path
        is an exact alias of this endpoint. Preflight (OPTIONS) is answered by this surface
        with Access-Control-Allow-Methods: POST, OPTIONS.
      parameters:
        - { name: MCP-Protocol-Version, in: header, required: false, schema: { type: string, enum: ['2025-11-25', '2025-06-18', '2024-11-05'] } }
      requestBody:
        required: true
        content:
          application/json: { schema: { type: object, required: [jsonrpc, method], additionalProperties: true } }
      responses:
        '200': { description: JSON-RPC response, content: { application/json: { schema: { type: object, required: [jsonrpc], additionalProperties: true } } } }
        '202': { description: Notification accepted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '406': { description: Accept header must include both MCP response media types }
        '413': { description: MCP request exceeds the 280 KiB limit, answered as a JSON-RPC -32600 error }
        '415': { description: Content-Type must be application/json }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
  /mcp:
    post:
      operationId: invokeEmailSecurityMcpUnversioned
      summary: Unversioned alias of /mcp/v2
      description: Behaves identically to /mcp/v2; kept for clients that discovered the endpoint before versioning.
      parameters:
        - { name: MCP-Protocol-Version, in: header, required: false, schema: { type: string, enum: ['2025-11-25', '2025-06-18', '2024-11-05'] } }
      requestBody:
        required: true
        content:
          application/json: { schema: { type: object, required: [jsonrpc, method], additionalProperties: true } }
      responses:
        '200': { description: JSON-RPC response, content: { application/json: { schema: { type: object, required: [jsonrpc], additionalProperties: true } } } }
        '202': { description: Notification accepted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '406': { description: Accept header must include both MCP response media types }
        '413': { description: MCP request exceeds the 280 KiB limit, answered as a JSON-RPC -32600 error }
        '415': { description: Content-Type must be application/json }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/QuotaAccountingUnavailable' }
components:
  headers:
    RetryAfter:
      description: 'Seconds until the caller may retry. Per-minute limits report 60; daily limits report the seconds until the next UTC midnight.'
      schema: { type: integer, minimum: 1 }
    Allow:
      description: HTTP methods the path supports.
      schema: { type: string, example: 'POST' }
    XReportRetentionDays:
      description: Days a bearer-link report is retained before deletion.
      schema: { type: integer, example: 14 }
  schemas:
    DomainRequest:
      type: object
      additionalProperties: false
      required: [domain]
      properties:
        domain: { type: string, maxLength: 253, example: example.com, description: Bare public DNS domain without scheme, path, or email local part. Special-use suffixes such as .local, .localhost, .internal, and .lan are refused. }
        share: { type: boolean, description: When true, persist a 14-day bearer report. Default false: the analysis is returned without inserting a D1 row. }
    HeaderRequest:
      type: object
      additionalProperties: false
      required: [headers]
      properties:
        # The analyzer accepts 256 KiB of header text, but the REST transport
        # caps the whole JSON body at 256 KiB and JSON escaping inflates CRLFs
        # (\r\n becomes 4 bytes), so ~250,000 characters is the realistic
        # ceiling for a single header block through this endpoint.
        headers: { type: string, maxLength: 262144, description: 'Complete RFC-style message header block only. The 256 KiB request-body cap, including JSON escaping, bounds what actually fits.' }
    SpfEvaluationRequest:
      type: object
      additionalProperties: false
      required: [ip]
      anyOf: [{ required: [domain] }, { required: [sender] }]
      properties:
        ip: { type: string, description: SMTP client IPv4 or IPv6 address. Private, loopback, and documentation ranges are accepted so lab fixtures can be evaluated; this is not a network probe. Hop enrichment still requires public addresses. }
        sender: { type: string, maxLength: 320 }
        helo: { type: string, maxLength: 253 }
        domain: { type: string, maxLength: 253 }
        record: { type: string, maxLength: 4096, description: Optional proposed SPF record to evaluate instead of live DNS. }
    RecordValidationRequest:
      type: object
      additionalProperties: false
      required: [type, record]
      properties:
        type: { type: string, enum: [spf, dmarc] }
        domain: { type: string, maxLength: 253 }
        record: { type: string, maxLength: 4096 }
    ValidationReport:
      type: object
      required: [valid, errors, warnings, request_budget]
      additionalProperties: true
      properties:
        valid: { type: boolean }
        errors: { type: array, items: { type: string } }
        warnings: { type: array, items: { type: string } }
        request_budget: { $ref: '#/components/schemas/RequestBudget' }
    RecordBuildRequest:
      type: object
      additionalProperties: false
      required: [type, domain]
      properties:
        type: { type: string, enum: [spf, dmarc] }
        domain: { type: string, maxLength: 253 }
        mechanisms: { type: array, maxItems: 30, items: { type: string, maxLength: 253 }, description: SPF mechanisms in evaluation order. }
        policy:
          type: string
          maxLength: 16
          pattern: '^(?:~all|-all|\?all|\+all|none|quarantine|reject)?$'
          description: SPF all policy (~all, -all, ?all, +all, or empty to omit) or DMARC enforcement policy (none, quarantine, reject). An unrecognized explicit value is rejected with 400 instead of silently substituted.
        rolloutStage: { type: string, enum: [testing, confirmed] }
        confirmsNoSenders: { type: boolean }
        testing: { type: string, enum: [y, n] }
        rua: { type: string, maxLength: 320 }
        subdomainPolicy: { type: string, enum: [none, quarantine, reject] }
        alignment: { type: string, enum: [relaxed, strict] }
        reviewedReports: { type: boolean }
    RecordBuildReport:
      type: object
      required: [type, host, record, validation, safetyWarnings, publishReady, request_budget]
      properties:
        type: { type: string, enum: [spf, dmarc] }
        host: { type: string }
        record: { type: string }
        validation: { $ref: '#/components/schemas/ValidationReport' }
        safetyWarnings: { type: array, items: { type: string } }
        publishReady: { type: boolean }
        request_budget: { $ref: '#/components/schemas/RequestBudget' }
    RequestBudget:
      type: object
      required: [limit, used, remaining, exhausted]
      properties:
        limit: { type: integer, minimum: 1, example: 45 }
        used: { type: integer, minimum: 0 }
        remaining: { type: integer, minimum: 0 }
        exhausted: { type: boolean }
        per_domain_limit: { type: integer, minimum: 1, description: Batch checks slice the shared budget evenly; this is each row's own cap. }
    ReportShare:
      type: object
      required: [available, retentionDays, bearer, cacheControl]
      properties:
        available: { type: boolean }
        id: { type: string, nullable: true }
        retentionDays: { type: integer, example: 14 }
        expiresAt: { type: string, format: date-time, nullable: true }
        bearer: { type: boolean, description: Always true because the link is a bearer credential. }
        cacheControl: { type: string, example: 'private, no-store' }
    BatchReport:
      type: object
      required: [_reportType, domains, results, created_at, validation, request_budget, share]
      properties:
        _reportType: { type: string, enum: [batch] }
        domains: { type: array, items: { type: string } }
        results: { type: array, items: { type: object, additionalProperties: true } }
        created_at: { type: string, format: date-time }
        id: { type: string }
        validation: { type: object, required: [accepted, rejected], properties: { accepted: { type: array, items: { type: string } }, rejected: { type: array, items: { type: object, additionalProperties: true } } } }
        request_budget: { $ref: '#/components/schemas/RequestBudget' }
        share: { $ref: '#/components/schemas/ReportShare' }
        source_revision: { type: string }
    DomainReport:
      type: object
      required: [domain, timestamp, source_revision, spf, dkim, dmarc, mx, caa, ptr, transport, dns, provenance, score_confidence, unknown_controls, request_budget, overall_score, overall_status, share]
      properties:
        domain: { type: string }
        timestamp: { type: string, format: date-time }
        freshness:
          type: object
          description: Domain observations are reused for up to five minutes. Upstream DNS TTLs may delay visibility of changes.
          properties:
            observedAt: { type: string, format: date-time }
            refreshAfter: { type: string, format: date-time }
            cached: { type: boolean }
        overall_score: { type: integer, minimum: 0, maximum: 100 }
        overall_status: { type: string, enum: [excellent, good, fair, poor] }
        score_confidence: { type: string, enum: [high, medium, low] }
        unknown_controls: { type: array, items: { type: string } }
        source_revision: { type: string }
        provenance: { type: object, required: [generatedAt, sourceRevision, dnsProviders, observation], additionalProperties: true }
        request_budget: { $ref: '#/components/schemas/RequestBudget' }
        share: { $ref: '#/components/schemas/ReportShare' }
        spf: { $ref: '#/components/schemas/CheckResult' }
        dkim: { $ref: '#/components/schemas/CheckResult' }
        dmarc: { $ref: '#/components/schemas/CheckResult' }
        mx: { $ref: '#/components/schemas/CheckResult' }
        caa: { $ref: '#/components/schemas/CheckResult' }
        ptr: { $ref: '#/components/schemas/CheckResult' }
        transport: { $ref: '#/components/schemas/CheckResult' }
        dns: { type: object, additionalProperties: true, description: Explicit DNS observation state for each queried control. }
    CheckResult:
      type: object
      additionalProperties: true
      description: Control-specific evidence, status, records, checks, warnings, and errors where applicable.
    HeaderReport:
      type: object
      required: [summary, checks, hops, ips, enrichment, limits]
      properties:
        summary: { $ref: '#/components/schemas/HeaderSummary' }
        checks:
          type: array
          items: { $ref: '#/components/schemas/HeaderCheck' }
        hops: { type: array, items: { type: object, additionalProperties: true } }
        ips: { type: array, maxItems: 10, items: { type: string } }
        enrichment: { type: array, items: { type: object, additionalProperties: true } }
        limits: { type: object, additionalProperties: true }
    HeaderSummary:
      type: object
      required: [status, verdict, confidence, passCount, spf, dkim, dmarc]
      properties:
        status: { type: string, enum: [pass, warn, fail], description: Never info — a paste without receiver reports carries the no-report warning. }
        verdict: { type: string }
        confidence: { type: string }
        passCount: { type: integer, minimum: 0, maximum: 3 }
        fromDomain: { type: string }
        returnDomain: { type: string }
        replyDomain: { type: string }
        spf: { type: string }
        dkim: { type: string }
        dmarc: { type: string }
    HeaderCheck:
      type: object
      required: [status, title, detail, recommendation]
      properties:
        status: { type: string, enum: [pass, warn, fail, info] }
        title: { type: string }
        detail: { type: string }
        recommendation: { type: string }
    Error:
      type: object
      additionalProperties: true
      properties:
        error: { type: string }
  responses:
    BadRequest:
      description: Invalid JSON or input
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Unexpected server fault; the analysis made no claim about the requested domain or headers
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    MethodNotAllowed:
      description: The path exists but not for this HTTP method
      headers:
        Allow: { $ref: '#/components/headers/Allow' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: 'Request limit reached; retry after the interval in the Retry-After header. Caps: 60 requests per minute for header analysis and REST report reads (standard class), 10 per minute for other REST analysis POSTs (expensive class), 6 per minute for MCP HTTP requests, 500 REST analysis POSTs per UTC day, 80 MCP analysis tool calls per UTC day, 120 REST report retrievals per UTC day, and 40 MCP report retrievals per UTC day. MCP get_email_security_report consumes the MCP report quota, not the analysis quota. When D1 daily accounting fails, analysis POSTs and report operations fail closed with 503.'
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BodyTooLarge:
      description: 'The request body exceeds the endpoint limit: 16 KiB of JSON for domain, SPF, builder, and enrichment endpoints; 256 KiB for header analysis; 280 KiB for MCP messages.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: Content-Type must be application/json. Simple CORS requests that send text/plain are refused before analysis quota is spent.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    QuotaAccountingUnavailable:
      description: Daily quota accounting in D1 is unavailable. Analysis POSTs and report operations fail closed until it recovers rather than running without the durable cap.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
