openapi: 3.1.0
info:
  title: NewsletterKit Public API
  version: "1.0.0"
  description: >
    Einheitliche, versionierte API zwischen Kundenplattformen und Postmark.
    NewsletterKit ist eine interne Middleware: Templates (Block-Stapel aus
    MJML-Blöcken, im Dashboard gebaut oder aus dem Builder des Kundenportals
    gespeichert) werden serverseitig gerendert und der Versand wird an
    Postmark übergeben. Verschickt wird immer ein Template. Der mitgeschickte API-Key bestimmt Kunde und Plan; der
    Postmark-Server-Token verlässt niemals den Server.


    Empfänger sind **transient** — sie werden über die Job-Laufzeit hinaus nicht
    gespeichert (keine PII).


    **Abrechnung:** Sie liegt vollständig bei Stripe. NewsletterKit meldet
    täglich die Zahl der versendeten E-Mails; Stripe bildet daraus eine
    Staffel (der Preis je E-Mail sinkt mit der Menge), rechnet monatlich
    rückwirkend ab und stellt das Ergebnis gemeinsam mit den anderen Produkten
    auf einer Rechnung. Es gibt keine Grundgebühr, kein Inklusivvolumen und
    keinen buchbaren Plan — und damit auch keine Endpunkte zum Buchen,
    Wechseln oder Kündigen.


    **Zugang:** Der Zugriff auf die API ist ein administrativer Schalter, kein
    Zahlungsstatus. Ist er gesperrt, bleiben lesende Endpunkte (Scope
    `reporting`) erreichbar, während versendende mit `403 forbidden` antworten.
    Die Freischaltung erfolgt über den Ansprechpartner.


    Diese Spec ist die **Single Source of Truth**: die Docs-Seite und das
    `llms.txt`-Bündel werden daraus generiert. Blöcke und Design-Regeln
    werden ausschließlich im Admin-Dashboard gepflegt; über die API sind sie
    nur lesbar. Block-Refs in einem Stapel referenzieren Blöcke über ihre
    unveränderliche `block_id` (UUID), nicht über den Slug. MJML-Templates
    unterstützen bedingte Abschnitte `{{#param}}…{{/param}}` (rendert nur bei
    nicht-leerem Wert). Newsletter gehen als Versandjob raus, Bilder kommen
    über den Asset-Upload, der Builder speichert seine Templates über die API.
servers:
  - url: /api/v1
    description: Versioniert unter /api/v1

security:
  - bearerAuth: []

tags:
  - name: System
    description: Verbindung & Health.
  - name: Blocks
    description: Freigeschaltete MJML-Blöcke des Kunden inkl. Parameter-Schema (read-only).
  - name: DesignRules
    description: Design-Tokens des Kunden (am Render injiziert; read-only).
  - name: Rendering
    description: Live-Vorschau (MJML + serverseitig kompiliertes, responsives HTML), versendet nichts.
  - name: Templates
    description: Templates des Kunden — lesen, aus dem Builder speichern, transaktional versenden.
  - name: Assets
    description: Bilder über NewsletterKit hochladen, optimiert und unter einer Hash-URL veröffentlicht.
  - name: Usage
    description: Versandmengen des Kunden aus dem Ledger, mit Monatslimit.
  - name: SendJobs
    description: Newsletter an viele Empfänger — Job anlegen, Empfänger anhängen, starten, abfragen.
  - name: Messages
    description: Status einer einzelnen Mail als Verlauf (über unsere Mail-ID).
  - name: Suppressions
    description: Sperrspiegel von Postmark je Kunde und Stream — abholen, prüfen, nach Double-Opt-in aufheben.
  - name: Billing
    description: >
      Tarif-Katalog, Abo-Status und Stripe-Self-Service (Checkout, Plan-Wechsel,
      Kündigung, Portal). Vom Plan-Gating ausgenommen — funktioniert auch ohne
      aktives Abo. Abgerechnet auf einer Stripe-Rechnung: Fixkosten monatlich im
      Voraus + Overage je 1.000 E-Mails nachgelagert (zzgl. 19 % USt.).
  - name: Webhooks
    description: Ausgehende Newsletter-Events registrieren (send.completed/failed, stats.updated).

paths:
  /health:
    get:
      operationId: getHealth
      tags: [System]
      summary: Authentifizierter Health-Check
      description: >
        Prüft, dass der API-Key gültig ist und die API erreichbar. Erfordert
        mindestens den Scope `reporting`.
      responses:
        "200":
          description: API erreichbar.
          content:
            application/json:
              schema:
                type: object
                required: [data, request_id]
                properties:
                  data:
                    type: object
                    required: [status, api_version]
                    properties:
                      status: { type: string, example: ok }
                      api_version: { type: string, example: v1 }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /blocks:
    get:
      operationId: listBlocks
      tags: [Blocks]
      summary: Freigeschaltete Blöcke lesen
      description: >
        Listet die für den Kunden freigeschalteten, aktiven Blöcke. Jeder
        Eintrag enthält die stabile `id` (UUID — der unveränderliche
        Referenzschlüssel), den `slug` (menschenlesbares Label) und das
        Parameter-Schema (`params`). Diese Liste sagt einem Client, welche
        Blöcke er in einer Komposition verwenden darf — referenziert wird ein
        Block über seine `id`, NICHT über den `slug` (Slugs/Namen können sich
        ändern, die `id` bleibt stabil und schützt gespeicherte Kompositionen).
        Es gibt keine Style-Bindung mehr: Jeder Block passt auf jeden Kunden,
        weil das Aussehen erst am Render aus dessen Design-Regeln entsteht.
        Welche Blöcke ein Kunde nutzen darf, entscheidet allein die
        Freischaltung. `series` nennt die Familie, aus der ein Block stammt —
        rein zum Gruppieren, eine Komposition darf Serien mischen. Read-only,
        Scope `reporting` genügt.
      responses:
        "200":
          description: Liste der freigeschalteten Blöcke.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        slug: { type: string, example: hero-text }
                        name: { type: string, example: Hero-Text }
                        tags:
                          type: array
                          description: >
                            Vom Admin gepflegte Tags des Blocks (mehrfach möglich).
                            Für Logik zählt der `slug`, `label` ist nur die Anzeige.
                            `header` und `footer` sind fest eingebaut.
                          items:
                            type: object
                            properties:
                              slug:  { type: string, example: content }
                              label: { type: string, example: Inhalt }
                        series:
                          type: object
                          nullable: true
                          description: >
                            Serie, aus der der Block stammt — höchstens eine, `null`
                            wenn keine gesetzt ist. Blöcke derselben Serie sind
                            zusammen entworfen und passen optisch zueinander; ein
                            Client kann danach gruppieren. Keine Bindung: Eine
                            Komposition darf Blöcke aus beliebig vielen Serien
                            mischen. Für Logik zählt der `slug`, `label` ist nur
                            die Anzeige.
                          properties:
                            slug:  { type: string, example: journal }
                            label: { type: string, example: Journal }
                        description: { type: string, nullable: true }
                        version: { type: integer, example: 1 }
                        params:
                          type: array
                          items: { $ref: "#/components/schemas/BlockParam" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /design-rules:
    get:
      operationId: getDesignRules
      tags: [DesignRules]
      summary: Design-Tokens des Kunden lesen
      description: >
        Gibt das vollständige, aufgelöste Design-Token-Set des Kunden zurück
        (Flächen-/Text-/Akzent-/Status-Farben, Typografie, Abstände, Komponenten;
        jeder Token bereits auf seinen konkreten Render-Wert aufgelöst). Diese
        Tokens werden serverseitig am Render injiziert und können über die API
        nicht verändert werden. Hat der Kunde keine eigenen Regeln, liefert die
        API vollständige Default-Tokens. Read-only, Scope `reporting`. Das
        Regelwerk steht unter /docs/design-rules.
      responses:
        "200":
          description: Design-Tokens.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/DesignRules" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /render/preview:
    post:
      operationId: renderPreview
      tags: [Rendering]
      summary: Live-Vorschau rendern
      description: >
        Rendert den übergebenen Stapel (`blocks`) mit seinen `values`
        serverseitig zu MJML und kompiliertem, responsivem HTML — die
        Live-Vorschau des Builders im Kundenportal. Zustandslos: Nichts wird
        gespeichert, und das Ergebnis ist genau das, was ein Template mit
        diesem Stapel beim Versand ergäbe. Der MJML-Compiler läuft serverseitig
        (`stub: false`), `html` ist also fertiges, in jedem Mail-Client
        darstellbares HTML. Design-Regeln und Marken-Werte des Kunden werden
        über das Grundgerüst angewendet. Bedingte MJML-Abschnitte
        `{{#param}}…{{/param}}` werden ausgewertet: Parameter ohne Wert werden
        aus dem gerenderten HTML weggelassen. Block-Refs referenzieren Blöcke
        über die `block_id` (UUID), nicht den Slug. Alle referenzierten Blöcke
        müssen für den Kunden freigeschaltet und aktiv sein — sonst
        `403 forbidden`. Versendet nichts und legt nichts an. Scope `full`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blocks]
              properties:
                blocks:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/BlockRef" }
                values:
                  type: object
                  additionalProperties: { type: string }
      responses:
        "200":
          description: Gerenderte Vorschau (kompiliertes, responsives HTML).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      mjml: { type: string }
                      html:
                        type: string
                        description: Fertiges, responsives HTML (serverseitig kompiliert).
                      errors:
                        type: array
                        description: MJML-Compiler-Warnungen/-Fehler (leer bei sauberem Render).
                        items:
                          type: object
                          properties:
                            line: { type: integer, nullable: true }
                            message: { type: string }
                            tagName: { type: string, nullable: true }
                      stub:
                        type: boolean
                        description: Immer false — der MJML-Compiler läuft serverseitig.
                        example: false
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /blocks/{id}/tile:
    get:
      operationId: getBlockTile
      tags: [Blocks]
      summary: Kachel eines Blocks (HTML im Kundendesign)
      description: >
        Der Block mit seinen Beispielwerten, gerendert im Design des Kunden —
        fertiges HTML für ein iframe mit `srcdoc`. Gecacht; ändert sich mit
        Block oder Design-Regeln. Scope `reporting`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Die Kachel.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      block_id: { type: string, format: uuid }
                      slug: { type: string }
                      html: { type: string, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: Templates des Kunden mit Vertrag
      responses:
        "200":
          description: Alle Templates des Kunden.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createTemplate
      tags: [Templates]
      summary: Stapel aus dem Builder als Template speichern
      description: >
        Speichert einen Newsletter-Stapel als eigenes Template (`origin:
        customer`). Kein Footer im Stapel (kommt aus dem Rahmen des Kunden);
        Werte nur für Parameter des Blocks; `{{platzhalter}}` werden je
        Empfänger beim Versandjob gefüllt. Scope `full`, idempotent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateWrite" }
      responses:
        "201":
          description: Angelegt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Template" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /templates/{id}:
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Ein Template mit Stapel
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Das Template.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Template" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      operationId: updateTemplate
      tags: [Templates]
      summary: Kunden-Template ändern
      description: >
        Nur Templates mit origin customer; Dashboard-Templates sind über die
        API nur lesbar (403). Alle Felder optional.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateWrite" }
      responses:
        "200":
          description: Geändert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Template" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteTemplate
      tags: [Templates]
      summary: Kunden-Template löschen
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Gelöscht.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /assets:
    post:
      operationId: createAsset
      tags: [Assets]
      summary: Bild anmelden, Upload-Adresse bekommen
      description: >
        Schritt 1 von 3. Die Datei geht danach per `PUT` mit ihrem
        `Content-Type` an `upload.url` (ohne API-Key, zwei Stunden gültig),
        dann `POST /assets/{id}/complete`. Scope `full`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, content_type]
              properties:
                filename: { type: string }
                content_type:
                  type: string
                  enum: [image/png, image/jpeg, image/webp, image/gif, image/avif, image/svg+xml]
      responses:
        "201":
          description: Angelegt, mit Upload-Adresse.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - { $ref: "#/components/schemas/Asset" }
                      - type: object
                        properties:
                          upload:
                            type: object
                            properties:
                              url: { type: string, format: uri }
                              method: { type: string, example: PUT }
                              headers:
                                type: object
                                additionalProperties: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
    get:
      operationId: listAssets
      tags: [Assets]
      summary: Fertige Bilder des Kunden
      parameters:
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Bilder, neueste zuerst.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Asset" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /assets/{id}:
    get:
      operationId: getAsset
      tags: [Assets]
      summary: Ein Bild
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Das Bild.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Asset" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteAsset
      tags: [Assets]
      summary: Bild löschen
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Gelöscht.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /assets/{id}/complete:
    post:
      operationId: completeAsset
      tags: [Assets]
      summary: Upload abschließen — optimieren und veröffentlichen
      description: >
        Schritt 3 von 3: Breite höchstens 1360 px, JPEG oder PNG (WebP/AVIF
        umgewandelt, SVG gerastert, animierte GIFs unverändert), unter 400 KB,
        ohne Metadaten. Die Adresse trägt den Inhalts-Hash und ändert sich
        nie. Gefahrlos wiederholbar. Scope `full`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Fertig.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Asset" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Noch keine Datei hochgeladen, oder die Datei ist kein lesbares Bild.

  /templates/{id}/send:
    post:
      operationId: sendTemplate
      tags: [Templates]
      summary: Transaktionale Mail aus einem Template versenden
      description: >
        Versendet eine transaktionale Mail aus dem Template `{id}` — synchron:
        Die Antwort kommt, wenn Postmark geantwortet hat, und trägt unsere
        Mail-ID. Das Template muss dem aufrufenden Kunden gehören und die Art
        transaktional haben; Newsletter gehen als Versandjob raus.


        Der Vertrag sind AUSSCHLIESSLICH die Template-Parameter: `params`
        liefert deren Werte; das Template leitet sie in Blöcke und Betreff, und
        `{{platzhalter}}` in festen Texten werden aus denselben Werten gefüllt.
        Unbekannte Parameter werden abgelehnt (422), fehlende Pflicht-Parameter
        ebenso. Pflicht-Parameter dürfen auch aus den Marken-Parametern des
        Kunden (Logo, Impressum …) gedeckt werden.


        Absender: Standard ist die No-Reply-Adresse des Kunden (oder die
        Ausnahme des Templates). `from.email` darf den Teil vor dem `@`
        ändern; die Domain muss beim Kunden hinterlegt sein.


        Gezählt wird, was Postmark annimmt (`status: accepted`). Im
        Sandbox-Modus des Kunden nimmt Postmark an, stellt nichts zu und es
        wird nichts gezählt. Scope `full`, idempotent über `Idempotency-Key`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, format: email, example: mira@example.com }
                params:
                  type: object
                  description: >
                    Werte der Template-Parameter (siehe Template im Admin),
                    z. B. titel oder link.
                  additionalProperties: { type: string }
                  example:
                    titel: Dein Zugang zum Dashboard
                    name: Mira
                    link: https://kunde.de/einladung?token=…
                from:
                  type: object
                  description: Absender-Ausnahme für diesen Aufruf.
                  properties:
                    email: { type: string, format: email, example: lena.berger@kunde.de }
                    name: { type: string, example: Lena Berger }
                reply_to: { type: string, format: email }
      responses:
        "200":
          description: Von Postmark beantwortet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message_id:
                        type: string
                        format: uuid
                        description: Unsere Mail-ID — der Schlüssel für den Status der Mail.
                      status:
                        type: string
                        enum: [accepted, suppressed, invalid]
                        description: >
                          accepted = von Postmark angenommen und gezählt;
                          suppressed = Empfänger bei Postmark gesperrt (Bounce, Abmeldung);
                          invalid = Adresse ungültig.
                      mode: { type: string, enum: [sandbox, live] }
                      template_id: { type: string, format: uuid }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: >
            Zugang gesperrt, Versand nicht freigeschaltet oder ein Block nicht
            freigeschaltet.
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Template inaktiv oder Idempotency-Key-Konflikt.
        "422":
          description: >
            Unbekannte oder fehlende (Pflicht-)Parameter, Newsletter-Template,
            Absender-Domain nicht hinterlegt oder ein Bild ließ sich nicht
            erzeugen (die Mail wurde dann nicht gesendet).
        "429":
          description: "Monatslimit des Kunden erreicht (code quota_exceeded)."

  /usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Versandmengen (laufender Monat, Hochrechnung, Vormonate)
      description: >
        Gibt die Mengen zurück, die dieser Kunde versendet hat: laufender Monat
        (getrennt nach newsletter/transactional), eine lineare Hochrechnung auf
        den ganzen Monat und die drei Vormonate.


        **Keine Beträge und kein Kontingent.** Abgerechnet wird nach
        tatsächlichem Versand über eine Staffel, die in Stripe hinterlegt ist;
        was der Verbrauch kostet, steht auf der Stripe-Rechnung. Ein zweiter Ort
        mit Geldbeträgen wäre eine zweite Wahrheit.


        Scope `reporting` — auch bei gesperrtem Zugang erreichbar.
      responses:
        "200":
          description: Mengen des Kunden.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Usage" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /send-jobs:
    post:
      operationId: createSendJob
      tags: [SendJobs]
      summary: Versandjob anlegen
      description: >
        Legt einen Job als Entwurf an. Das Template muss dem Kunden gehören,
        aktiv und ein Newsletter sein; `params` sind die Werte seiner
        Template-Parameter. `from.email` darf den Teil vor dem `@` ändern —
        die Domain muss beim Kunden hinterlegt sein. Scope `full`, idempotent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id]
              properties:
                template_id: { type: string, format: uuid }
                params:
                  type: object
                  additionalProperties: { type: string }
                from:
                  type: object
                  properties:
                    email: { type: string, format: email }
                    name: { type: string }
                reply_to: { type: string, format: email }
                scheduled_at: { type: string, format: date-time }
      responses:
        "201":
          description: Job angelegt (Status `draft`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SendJob" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
    get:
      operationId: listSendJobs
      tags: [SendJobs]
      summary: Jobs des Kunden
      parameters:
        - in: query
          name: status
          required: false
          schema: { type: string, enum: [draft, scheduled, running, paused, completed, cancelled, failed] }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: Die letzten Jobs, neueste zuerst.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SendJob" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /send-jobs/{id}:
    get:
      operationId: getSendJob
      tags: [SendJobs]
      summary: Status, Zähler und Auswertung eines Jobs
      description: >
        Zähler je Ergebnis, Summen aus den Postmark-Ereignissen (`stats`) und
        dieselben Zahlen je Stunde (`timeline`). Scope `reporting`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Der Job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - { $ref: "#/components/schemas/SendJob" }
                      - type: object
                        properties:
                          stats: { $ref: "#/components/schemas/JobStats" }
                          timeline:
                            type: array
                            items:
                              allOf:
                                - type: object
                                  properties:
                                    bucket: { type: string, format: date-time }
                                - { $ref: "#/components/schemas/JobStats" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /send-jobs/{id}/recipients:
    post:
      operationId: appendSendJobRecipients
      tags: [SendJobs]
      summary: Empfänger anhängen (bis 5.000 je Aufruf)
      description: >
        Nur im Entwurf. Jeder Empfänger bekommt sofort seine Mail-ID; eine
        Adresse ist je Job nur einmal drin. `data` füllt `{{platzhalter}}`
        dieses Empfängers. Scope `full`, idempotent je Aufruf.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipients]
              properties:
                recipients:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: object
                    required: [email]
                    properties:
                      email: { type: string, format: email }
                      data:
                        type: object
                        additionalProperties: { type: string }
      responses:
        "200":
          description: Angehängt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      added: { type: integer }
                      duplicates: { type: integer }
                      total: { type: integer }
                      recipients:
                        type: array
                        items:
                          type: object
                          properties:
                            email: { type: string, format: email }
                            message_id: { type: string, format: uuid }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429":
          description: "Höchstzahl je Job erreicht (code quota_exceeded)."

  /send-jobs/{id}/start:
    post:
      operationId: startSendJob
      tags: [SendJobs]
      summary: Job starten
      description: >
        Sofort oder zu `scheduled_at`. Prüft Freischaltung, Newsletter-Footer,
        Pflicht-Parameter und im Live-Modus das Monatslimit. Scope `full`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scheduled_at: { type: string, format: date-time }
      responses:
        "200":
          description: Gestartet (`running`) oder geplant (`scheduled`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SendJob" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429":
          description: "Monatslimit erreicht (code quota_exceeded)."

  /send-jobs/{id}/cancel:
    post:
      operationId: cancelSendJob
      tags: [SendJobs]
      summary: Job abbrechen
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Abgebrochen; angenommene Mails sind raus, alles Übrige nicht.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SendJob" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /send-jobs/{id}/messages:
    get:
      operationId: listSendJobMessages
      tags: [SendJobs]
      summary: Mails eines Jobs nach Status
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [pending, accepted, suppressed, invalid, failed, unknown, cancelled, delivered, bounced, complained]
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 500, default: 500 }
        - in: query
          name: after
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Mails mit Zustand; `email` nur, solange der Job läuft.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        message_id: { type: string, format: uuid }
                        status: { type: string }
                        email: { type: string, nullable: true }
                        bounce_type: { type: string, nullable: true }
                        error: { type: string, nullable: true }
                        delivered_at: { type: string, format: date-time, nullable: true }
                        first_opened_at: { type: string, format: date-time, nullable: true }
                        first_clicked_at: { type: string, format: date-time, nullable: true }
                        bounced_at: { type: string, format: date-time, nullable: true }
                        complained_at: { type: string, format: date-time, nullable: true }
                  paging:
                    type: object
                    properties:
                      after: { type: string, nullable: true }
                      has_next: { type: boolean }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /send-jobs/{id}/links:
    get:
      operationId: listSendJobLinks
      tags: [SendJobs]
      summary: Klicks je Link
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Links, meistgeklickt zuerst.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        url: { type: string }
                        clicks: { type: integer }
                        unique_clicks: { type: integer }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /messages/{id}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Status einer Mail als Verlauf
      description: >
        Der zuletzt bekannte Zustand einer Mail und ihr Verlauf aus den
        Postmark-Ereignissen. Die `id` ist die `message_id` aus der Antwort des
        Versands. Nur eigene Mails (sonst 404); die Adresse steht nirgends in
        der Antwort. Scope `reporting`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Zustand und Verlauf.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Message" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /suppressions:
    get:
      operationId: listSuppressions
      tags: [Suppressions]
      summary: Sperränderungen seit Zeitpunkt
      description: >
        Alle Sperränderungen des Kunden seit `since`, chronologisch — gesetzte
        (`active: true`) und aufgehobene (`active: false`). Für einen Cron im
        Kundensystem, der sein Sperrfeld nachzieht. Die Einwilligung liegt im
        CRM, die Sperre bei Postmark; NewsletterKit spiegelt sie. Scope
        `reporting`.
      parameters:
        - in: query
          name: since
          required: true
          schema: { type: string, format: date-time }
          description: Änderungen ab diesem Zeitpunkt (inklusive).
        - in: query
          name: stream
          required: false
          schema: { type: string, enum: [newsletter, transactional], default: newsletter }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 500, default: 500 }
        - in: query
          name: after
          required: false
          schema: { type: string }
          description: Cursor aus `paging.after` der vorigen Seite.
      responses:
        "200":
          description: Sperränderungen, chronologisch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Suppression" }
                  paging:
                    type: object
                    properties:
                      after: { type: string, nullable: true }
                      has_next: { type: boolean }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /suppressions/lookup:
    post:
      operationId: lookupSuppressions
      tags: [Suppressions]
      summary: Adressen auf aktive Sperre prüfen
      description: >
        Prüft bis zu 500 Adressen gegen den Sperrspiegel. `POST` statt `GET`,
        damit Adressen nicht in URLs und Server-Logs landen. Scope `reporting`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails:
                  type: array
                  maxItems: 500
                  items: { type: string, format: email }
                stream:
                  type: string
                  enum: [newsletter, transactional]
                  default: newsletter
      responses:
        "200":
          description: Status je Adresse.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [email, suppressed]
                      properties:
                        email: { type: string, format: email }
                        suppressed: { type: boolean }
                        reason:
                          type: string
                          enum: [unsubscribe, hard_bounce, spam_complaint]
                        reactivatable: { type: boolean }
                        since: { type: string, format: date-time }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /suppressions/reactivate:
    post:
      operationId: reactivateSuppression
      tags: [Suppressions]
      summary: Sperre nach neuem Double-Opt-in aufheben
      description: >
        Hebt die Sperre einer Adresse bei Postmark auf. Nur nach einem neuen,
        bestätigten Double-Opt-in — nie, weil jemand im CRM einen Haken
        gesetzt hat. Nach einer Spam-Beschwerde ist keine Aufhebung möglich:
        `409 conflict` mit `details.reason = "spam_complaint"`. Gefahrlos
        wiederholbar. Scope `full`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                stream:
                  type: string
                  enum: [newsletter, transactional]
                  default: newsletter
      responses:
        "200":
          description: Sperre aufgehoben (oder war bereits aufgehoben).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      email: { type: string, format: email }
                      stream: { type: string, enum: [newsletter, transactional] }
                      reactivated: { type: boolean }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: Registrierte Webhooks lesen
      description: >
        Listet die Webhook-Endpoints des Kunden (ohne Signatur-Secret). Scope
        `reporting`.
      responses:
        "200":
          description: Liste der Endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string, format: uri }
                        events: { type: array, items: { type: string } }
                        status: { type: string, enum: [active, inactive] }
                        last_delivery_at: { type: string, format: date-time, nullable: true }
                        failure_count: { type: integer }
                        created_at: { type: string, format: date-time }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: registerWebhook
      tags: [Webhooks]
      summary: Webhook-URL registrieren
      description: >
        Registriert eine HTTPS-URL für Newsletter-Events (`send.completed`,
        `send.failed`, `stats.updated`). Die Antwort enthält EINMALIG ein
        `signing_secret`; jede Zustellung wird damit per HMAC-SHA256 signiert
        (Header `X-NewsletterKit-Signature: t=<unix>,v1=<hmac>`, gebildet über
        `"<t>.<roher Body>"`). Scope `full`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS-Endpunkt der Kundenplattform.
                  example: "https://kunde.de/hooks/newsletterkit"
                events:
                  type: array
                  items:
                    type: string
                    enum: [send.completed, send.failed, stats.updated]
                  default: [send.completed]
                  example: [send.completed, send.failed]
      responses:
        "201":
          description: Webhook registriert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      url: { type: string, format: uri }
                      events: { type: array, items: { type: string } }
                      active: { type: boolean }
                      signing_secret:
                        type: string
                        description: Nur einmalig — danach nicht mehr abrufbar.
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Kunden-API-Key als `Authorization: Bearer <key>`. Pro Kunde rotierbar,
        mit Scope (`full` oder `reporting`). Demo-Key (Seed): `nk_demo_secret`.

  responses:
    Unauthorized:
      description: Kein oder ungültiger API-Key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: API-Key gültig, aber Scope reicht nicht.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate-Limit überschritten.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Ungültige Eingabe.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Idempotenz-Konflikt oder Statuskollision.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Ressource nicht gefunden (oder gehört nicht zum Kunden).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotImplemented:
      description: Endpunkt noch nicht verfügbar (coming soon).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    BlockParam:
      type: object
      properties:
        name: { type: string, example: headline }
        label: { type: string, nullable: true, example: Überschrift }
        type:
          type: string
          enum: [string, text, url, image, color, boolean, number]
        required: { type: boolean }
        fallback: { type: string, nullable: true }
        hide_if_missing: { type: boolean }
        example: { type: string, nullable: true }
        description:
          type: string
          nullable: true
          description: >
            Was inhaltlich in dieses Feld gehört. Der Parametername allein sagt
            das nicht — `title` verrät nicht, ob eine Schlagzeile oder ein
            Produktname erwartet wird. Für aufrufende Systeme geschrieben,
            ausdrücklich auch für Modelle, die ein Template automatisiert
            befüllen.
          example: Schlagzeile der Veranstaltung, ohne Datum.
        min_chars:
          type: integer
          nullable: true
          minimum: 0
          description: >
            Zugesagte Mindestlänge in Zeichen; `null` = keine Untergrenze.
          example: 10
        max_chars:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Zugesagte Höchstlänge in Zeichen; `null` = keine Obergrenze. Sie ist
            eine **Zusage, keine Schranke**: Der Versand weist längere Werte
            nicht zurück. Wer sie überschreitet, riskiert aber ein Layout, das
            sich in der verschickten Mail nicht mehr korrigieren lässt.
          example: 45
        is_list:
          type: boolean
          description: >
            `true` = **Liste**: Der Parameter erwartet ein Array von Objekten in
            der Form seiner `fields`, nicht einen Einzelwert. Der Block
            wiederholt seinen Abschnitt je Element — die Zahl der Elemente ist
            frei, eine leere Liste lässt den Abschnitt entfallen.

            Ohne diese Angabe wäre eine Liste nicht von einem zusammengesetzten
            Parameter zu unterscheiden: Beide melden `fields`, aber die eine
            will `[{…}, {…}]`, der andere `{…}`.
          example: false
        fields:
          type: array
          description: >
            Die Bestandteile eines zusammengesetzten Parameters oder — bei
            `is_list: true` — die Form EINES Listenelements. Fehlt das Feld,
            trägt der Parameter einen einfachen Wert.
          items: { $ref: "#/components/schemas/BlockParam" }
    BlockRef:
      type: object
      required: [block_id]
      properties:
        block_id:
          type: string
          format: uuid
          description: >
            Unveränderliche `id` (UUID) eines für den Kunden freigeschalteten
            Blocks (aus `GET /v1/blocks`, Feld `id`) — NICHT der `slug`. Slugs
            und Namen können sich ändern, die `id` bleibt stabil und schützt
            gespeicherte Kompositionen vor Brüchen.
          example: b1a2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d
        values:
          type: object
          description: >
            Param-Key → Wert. Ein Wert ist **Text** oder, bei einem
            zusammengesetzten Parameter, ein **Objekt mit seinen Feldern**.

            Zusammengesetzt sind Parameter, die inhaltlich zusammengehören und
            sonst einzeln vergessen würden. Ein Bild etwa besteht aus Adresse
            und Alt-Text; welche Felder ein Parameter hat, sagt
            `GET /v1/blocks` im Feld `fields`.

            Alternativ dürfen die Felder auch flach mit Punkt übergeben werden
            (`"bild.url"`), was beim Zusammenbauen aus einer flachen Datenquelle
            hilft. Beides ergibt dasselbe Ergebnis.

            Ist ein Parameter eine **Liste** (`GET /v1/blocks` meldet
            `is_list: true`), erwartet er ein **Array von Objekten** in der Form
            seiner `fields` — beliebig viele Elemente, ohne feste Obergrenze.
            Der Block wiederholt seinen Abschnitt je Element; eine leere Liste
            lässt ihn ganz entfallen. Die punktierte Kurzform gibt es hier
            nicht: Sie müsste die Position in den Schlüssel schreiben und damit
            die Länge festschreiben.
          additionalProperties:
            oneOf:
              - type: string
              - type: object
                additionalProperties: { type: string }
              - type: array
                items:
                  type: object
                  additionalProperties: { type: string }
          example:
            {
              headline: "Hallo",
              bild: { url: "https://…/buehne.jpg", alt: "Bühne bei Sonnenuntergang" },
              artists:
                [
                  { name: "MALUGI", genre: "Trance", instagram_url: "https://instagram.com/malugienergy/" },
                  { name: "DARIA KOLOSOVA", genre: "Techno" },
                ],
            }
    Usage:
      type: object
      description: >
        Reine Mengen. Preise stehen nicht darin: Abgerechnet wird nach Staffel
        in Stripe, und die Rechnung dort ist die einzige Wahrheit.
      properties:
        current_month:
          type: object
          properties:
            period_month: { type: string, format: date, example: "2026-09-01" }
            emails_sent: { type: integer, example: 12480 }
            emails_newsletter: { type: integer, example: 9330 }
            emails_transactional: { type: integer, example: 3150 }
            projected_emails:
              type: integer
              nullable: true
              description: >
                Lineare Fortschreibung des bisherigen Tempos auf den ganzen
                Monat — eine Schätzung, keine Zusage. Am ersten Tag `null`.
              example: 41600
            days_elapsed: { type: integer, example: 9 }
            days_in_month: { type: integer, example: 30 }
        previous_months:
          type: array
          items:
            type: object
            properties:
              period_month: { type: string, format: date }
              emails_sent: { type: integer }
              emails_newsletter: { type: integer }
              emails_transactional: { type: integer }
    Template:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        subject: { type: string, nullable: true }
        kind: { type: string, enum: [transaktional, newsletter] }
        status: { type: string, enum: [draft, active, archived] }
        origin:
          type: string
          enum: [admin, customer]
          description: admin = im Dashboard gebaut (über die API nur lesbar), customer = aus dem Builder gespeichert.
        from:
          type: object
          nullable: true
          properties:
            email: { type: string, format: email }
            name: { type: string, nullable: true }
        reply_to: { type: string, nullable: true }
        params:
          type: array
          description: Der Parameter-Vertrag (bei Kunden-Templates leer).
          items:
            type: object
            properties:
              name: { type: string }
              label: { type: string, nullable: true }
              type: { type: string }
              required: { type: boolean }
              fallback: { type: string, nullable: true }
              example: { type: string, nullable: true }
        blocks:
          type: array
          description: Nur in der Detailansicht.
          items:
            type: object
            properties:
              block_id: { type: string, format: uuid }
              block_slug: { type: string, nullable: true }
              values: { type: object }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TemplateWrite:
      type: object
      properties:
        name: { type: string }
        subject: { type: string }
        blocks:
          type: array
          items:
            type: object
            required: [block_id]
            properties:
              block_id: { type: string, format: uuid }
              values:
                type: object
                description: Werte je Parameter des Blocks; zusammengesetzte als Objekt, z. B. bild { url, alt }.
        status: { type: string, enum: [draft, active] }
    Asset:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [pending, ready] }
        filename: { type: string, nullable: true }
        content_type: { type: string, nullable: true }
        url: { type: string, format: uri, nullable: true }
        width: { type: integer, nullable: true }
        height: { type: integer, nullable: true }
        bytes: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
    JobStats:
      type: object
      properties:
        accepted: { type: integer }
        suppressed: { type: integer }
        delivered: { type: integer }
        bounced: { type: integer }
        opened: { type: integer }
        opened_unique: { type: integer }
        clicked: { type: integer }
        clicked_unique: { type: integer }
        complained: { type: integer }
        unsubscribed: { type: integer }
    SendJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [draft, scheduled, running, paused, completed, cancelled, failed]
        mode: { type: string, enum: [sandbox, live] }
        template_id: { type: string, format: uuid }
        params:
          type: object
          additionalProperties: { type: string }
        from:
          type: object
          nullable: true
          properties:
            email: { type: string, format: email }
            name: { type: string, nullable: true }
        reply_to: { type: string, nullable: true }
        scheduled_at: { type: string, format: date-time, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }
        cancelled_at: { type: string, format: date-time, nullable: true }
        paused_reason: { type: string, nullable: true }
        error: { type: string, nullable: true }
        recipients:
          type: object
          properties:
            total: { type: integer }
            accepted: { type: integer }
            suppressed: { type: integer }
            invalid: { type: integer }
            failed: { type: integer }
            pending: { type: integer }
        batches: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
    Message:
      type: object
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [transactional, newsletter] }
        mode: { type: string, enum: [sandbox, live] }
        status:
          type: string
          enum: [pending, accepted, suppressed, invalid, failed, unknown, delivered, bounced, complained]
        template_id: { type: string, format: uuid, nullable: true }
        send_job_id: { type: string, format: uuid, nullable: true }
        subject: { type: string, nullable: true }
        bounce_type: { type: string, nullable: true }
        error: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        delivered_at: { type: string, format: date-time, nullable: true }
        first_opened_at: { type: string, format: date-time, nullable: true }
        first_clicked_at: { type: string, format: date-time, nullable: true }
        bounced_at: { type: string, format: date-time, nullable: true }
        complained_at: { type: string, format: date-time, nullable: true }
        events:
          type: array
          items:
            type: object
            properties:
              type:
                type: string
                enum: [delivery, bounce, spam_complaint, open, click, subscription_change]
              at: { type: string, format: date-time }
              details: { type: object }
    Suppression:
      type: object
      description: >
        Eine Sperränderung aus dem Spiegel. `active: false` ist eine
        aufgehobene Sperre — auch die muss ins CRM.
      required: [email, stream, active, reason, changed_at]
      properties:
        email: { type: string, format: email }
        stream: { type: string, enum: [newsletter, transactional] }
        active: { type: boolean }
        reason:
          type: string
          enum: [unsubscribe, hard_bounce, spam_complaint]
        reactivatable:
          type: boolean
          description: Bei `spam_complaint` immer `false`.
        changed_at: { type: string, format: date-time }
    Error:
      type: object
      required: [error, request_id]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - bad_request
                - unauthorized
                - forbidden
                - not_found
                - validation_error
                - conflict
                - quota_exceeded
                - payment_required
                - rate_limited
                - internal_error
                - not_implemented
            message: { type: string }
            details:
              description: Optionale, strukturierte Zusatzinfos.
        request_id: { type: string, format: uuid }
