openapi: 3.1.0
info:
  title: mailronix — API di integrazione
  version: "1.13.0"
  description: |
    Documenta la superficie API di mailronix raggiungibile da un sistema
    esterno tramite API key: invio (ADR-0029, esteso da ADR-0030 con
    template grafici e variabili) e gestione del ciclo di vita dei
    contatti (ADR-0029 primo emendamento).

    ## Cosa può fare una chiave: le capacità

    Una chiave non può fare tutto ciò che questo documento descrive.
    Porta un **insieme di capacità**, scelto quando la crei in console e
    **non più modificabile**: per aggiungerne una crei una chiave nuova e
    ritiri la vecchia. Non c'è un default e non c'è un insieme completo —
    una chiave fa esattamente ciò per cui l'hai creata.

    | Capacità | Endpoint |
    |---|---|
    | `email:send` | `POST /email/send` |
    | `contacts:write` | `POST /contacts` |
    | `contacts:unsubscribe` | `POST /contacts/revoke` |
    | `contacts:delete` | `POST /contacts/delete` |
    | `revocations:read` | `GET /contacts/revocations` |
    | `email:read` | `GET /email/{id}` |

    Una richiesta a un endpoint per cui la chiave non ha la capacità
    riceve `403 capability_not_granted`. È un rifiuto definitivo: crea
    una chiave con le capacità che ti servono, non ritentare.

    **`revocations:read` non è implicita in nessun'altra capacità.** In
    particolare non la ottieni con `contacts:unsubscribe`: scrivere una
    revoca è un'operazione di cui conosci già l'esito e il cui oggetto hai
    fornito tu, leggere l'elenco è l'unica operazione di questa API che ti
    restituisce indirizzi che non ci hai dato. Sono due autorizzazioni
    diverse, e le scegli tu alla creazione della chiave. Nessuna chiave
    creata prima dell'introduzione di questa capacità l'ha acquistata.

    **`email:read` non è implicita in `email:send`**, per lo stesso
    motivo: inviare e leggere l'esito di un invio sono due autorizzazioni
    distinte. Una chiave che sa solo inviare non può interrogare lo stato
    di nulla.

    **`contacts:delete` non è concedibile senza `contacts:unsubscribe`.**
    Una chiave che può cancellare ma non revocare porta a implementare
    «l'utente ha tolto la spunta» come una cancellazione: la disiscrizione
    non viene registrata, la sincronizzazione successiva ricrea il
    contatto, e parte una nuova richiesta di conferma verso chi si era
    appena disiscritto.

    ## Il consenso lo raccogliamo noi

    L'API **non consente in alcun modo di creare un Contatto già
    confermato né di dichiarare un consenso raccolto altrove**. Un
    contatto creato via API nasce non confermato, e la richiesta di
    conferma parte da noi con la stessa prova di sempre: è il click del
    destinatario a valere come consenso, non la tua dichiarazione.

    Ogni altro endpoint del backend (gestione API key, template, campagne,
    billing, area riservata) richiede una sessione browser autenticata via
    cookie ed è un'azione di prodotto, non una superficie di integrazione
    server-to-server — deliberatamente fuori perimetro di questo documento
    (ADR-0032). Non esiste alcun modo di far partire una campagna via API.

    ## Webhook in uscita: noi chiamiamo te

    Per non fare polling puoi registrare, **dalla console** (non via API),
    uno o più indirizzi HTTPS a cui mailronix invia gli eventi del tuo
    account. Endpoint attivi: Free 1, Starter 5, Growth 10. Ogni evento è
    un `POST` con corpo JSON firmato: la forma, la verifica della firma e i
    tentativi sono nella sezione `webhooks` di questo documento. Non
    esistono eventi di apertura: non le rileviamo. L'evento `email.clicked`
    esiste solo per i brand che hanno acceso, dalla console, il
    tracciamento dei click (spento per impostazione iniziale), e descrive un
    click **rilevato** da un destinatario che aveva acconsentito: scanner
    e anteprime possono generarlo senza un gesto umano.
servers:
  - url: https://api.mailronix.com
    description: Produzione
  - url: http://localhost:8080
    description: Sviluppo locale
paths:
  /email/send:
    post:
      operationId: sendTransactionalEmail
      summary: Invia un'email transazionale
      description: |
        Accoda un'email transazionale per l'invio. La categoria è sempre
        `transaction_receipt`, determinata esclusivamente lato server —
        non esiste alcun modo di selezionarla dalla richiesta.

        Il corpo del messaggio si specifica in una delle due modalità
        seguenti, mai entrambe nella stessa richiesta:

        - **Contenuto diretto**: `subject` e almeno uno tra `html_body`/
          `text_body`.
        - **Da template**: `template_id` (creato in console,
          `POST /email-templates`, fuori perimetro di questo documento) e
          `variables`, una mappa `nome → valore` sostituita nei
          segnaposto `{{nome}}` del template.

        ## Il brand del messaggio

        Ogni invio parte sotto un **brand** — il marchio con cui il
        destinatario ti riconosce. Su questo endpoint il brand **non si
        dichiara**: si ricava dalla **chiave API** che autentica la
        richiesta, a cui è stato legato quando l'hai creata in console. Il
        legame è immutabile: una chiave scrive sempre e solo sotto quel
        brand, qualunque cosa accada alla configurazione del tuo account
        nel frattempo.

        Se hai più brand, crei **una chiave per brand**. Questo è anche
        ciò che limita il danno se una chiave ti sfugge: porta un brand
        solo.

        Il campo `brand_id` è **facoltativo** e serve a una cosa sola,
        proteggerti da uno scambio di chiavi fra due integrazioni: se lo
        mandi, deve coincidere con il brand della chiave, altrimenti la
        richiesta è rifiutata con `403 brand_mismatch`. Non può
        **scegliere** il brand — con una chiave lo scambio di segreti è
        altrimenti invisibile, perché produrrebbe messaggi validi sotto il
        marchio sbagliato.

        Se autentichi con la **sessione cookie** della console (per una
        prova dagli strumenti di sviluppo) il brand va invece dichiarato,
        ed è obbligatorio: lì non c'è una credenziale che lo porti.

        La risposta di successo è **identica** indipendentemente dal
        fatto che il destinatario sia effettivamente recapitabile o si
        trovi in una suppression list per bounce/complaint pregressi
        (ADR-0017): nessun campo della risposta permette di distinguere
        i due casi, per non offrire a un chiamante un modo di verificare
        l'esistenza/validità di un indirizzo email altrui.

        ## Il flag marketing

        Per impostazione predefinita questo endpoint invia messaggi
        **transazionali**: ricevute, conferme di operazione, notifiche di
        servizio. Sono inviabili a **chiunque**, anche a chi non è un tuo
        contatto e anche a chi si è disiscritto — la loro base giuridica
        non è il consenso, ed è la ragione per cui chi si disiscrive
        continua a ricevere gli avvisi di sicurezza del tuo servizio.

        Con `"marketing": true` dichiari che il messaggio è una
        comunicazione promozionale. La dichiarazione può soltanto
        **TOGLIERE**: aggiunge controlli e non ne rimuove nessuno, e non
        esiste alcun valore di questo campo che qualifichi un messaggio
        come transazionale — quella qualifica è determinata dal server e
        non è dichiarabile.

        Un messaggio marketing:

        - parte **solo** verso un indirizzo che è un tuo contatto di quel
          brand e che ha **confermato** l'iscrizione;
        - **non parte** verso chi ha revocato il consenso presso quel
          brand;
        - porta **automaticamente** il link di disiscrizione nel corpo e
          l'header `List-Unsubscribe` (RFC 8058). Non c'è modo di ometterli;
        - si ferma se il canale del tuo brand è sospeso, come una campagna.

        Se il destinatario non è eleggibile la risposta è
        `403 recipient_not_eligible_for_marketing`. **Un codice solo per
        due casi diversi** — non è un tuo contatto confermato, oppure ha
        revocato: distinguerli direbbe a chi possiede la chiave se una
        data persona è tua cliente e se ti ha detto di no. Per raccogliere
        il consenso usa `POST /contacts`.

        ## Le due parti del messaggio: HTML e testo

        Il destinatario riceve **sempre** un messaggio a due parti
        (`multipart/alternative`, HTML e testo semplice) con **entrambe
        popolate**: una parte vuota non viene mai spedita. Se ne fornisci
        una sola, l'altra viene derivata da quella che hai mandato; se le
        fornisci entrambe, partono come le hai scritte, senza modifiche.
        Una parte contenente solo spazi vale come assente.

        Questo conta perché in un `multipart/alternative` una parte vuota
        **non** equivale a una parte assente: il client sceglie
        l'alternativa più ricca, la trova vuota e mostra un messaggio
        bianco. Mandare una sola delle due parti è quindi un'integrazione
        legittima e sicura, non un mezzo messaggio.

        Vale allo stesso modo per la modalità `template_id`: un template
        produce solo HTML, quindi la parte testo è sempre derivata.

        ### Se mandi solo `html_body`

        La parte testo è il tuo HTML ridotto a testo leggibile:

        - i link diventano `etichetta(url)`: l'indirizzo compare in
          chiaro, fra parentesi, subito dopo il testo del link. Nessun
          link va quindi perso per chi legge la versione testuale — un
          link di conferma, di disiscrizione o di annullamento deve
          restare raggiungibile in entrambe le parti, ed è un requisito
          legale, non una scelta estetica. L'URL è omesso solo quando non
          aggiunge nulla: ancora interna (`#...`) o etichetta già
          identica all'indirizzo;
        - le immagini diventano il loro testo alternativo fra parentesi
          quadre (`[Logo]`); quelle senza `alt` spariscono;
        - le voci di elenco diventano righe che iniziano con `- `;
        - paragrafi e titoli restano separati da una riga vuota, righe e
          celle di tabella da un a capo semplice; `<br>` resta un a capo
          singolo;
        - `<script>`, `<style>` e il `<title>` non finiscono nel testo.

        Esempio — questo `html_body`:

        ```html
        <p>Ordine #4821 confermato.
        <a href="https://negozio.example/ordini/4821">Vedi il dettaglio</a></p>
        ```

        produce questa parte testo:

        ```text
        Ordine #4821 confermato. Vedi il dettaglio (https://negozio.example/ordini/4821)
        ```

        ### Se mandi solo `text_body`

        La parte HTML è il tuo testo avvolto in un layout minimo e
        neutro: le righe vuote separano i paragrafi, gli a capo singoli
        restano tali, gli URL riconoscibili diventano cliccabili. Il
        testo è sempre escapato — non viene interpretato come markup —
        quindi eventuali `<` e `&` arrivano come li hai scritti.

        Il layout **non** aggiunge il marchio mailronix né alcuna riga di
        piè di pagina: il messaggio parte dal tuo dominio verificato e
        deve continuare a essere riconoscibile come tuo.

        ### Se vuoi controllo pieno sulle due parti

        Mandale entrambe. La derivazione è una rete di sicurezza per chi
        ne fornisce una sola, non una riscrittura di ciò che fornisci:
        una parte che hai valorizzato non viene mai toccata.

        ## Ritentare in sicurezza: `Idempotency-Key`

        Un timeout o un errore di rete non ti dice se la richiesta è
        stata accodata: ritentare senza precauzioni può duplicare
        l'invio. Manda l'header `Idempotency-Key` (una stringa a tua
        scelta, univoca per questo invio — un id d'ordine, per esempio) e
        un retry con lo **stesso** header e lo **stesso corpo** riceve la
        risposta della prima chiamata, `email_log_id` incluso, senza
        accodare una seconda volta.

        - Valida per **24 ore** dalla prima chiamata; dopo, lo stesso
          valore può essere riusato per un invio nuovo.
        - Legata alla **chiave API**: solo sull'autenticazione a chiave,
          mai a sessione. Due chiavi diverse non collidono mai sullo
          stesso valore, nemmeno dello stesso brand.
        - Stesso header, corpo **diverso** dalla prima chiamata →
          `422 idempotency_key_body_mismatch`. Non è un'ambiguità
          risolta in silenzio: riusare un nome per una richiesta diversa
          è un errore da correggere lato tuo, non un'esecuzione
          alternativa.
        - Facoltativo: senza l'header il comportamento è quello di
          sempre, un retry è un secondo invio.
      security:
        - apiKey: []
        - sessionCookie: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: >-
            Vedi "Ritentare in sicurezza" sopra. Ignorato silenziosamente
            sull'autenticazione a sessione cookie.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
            examples:
              contenutoDiretto:
                summary: Contenuto diretto (senza template)
                value:
                  from: "notifiche@cliente-esempio.com"
                  to: "utente@destinatario.com"
                  subject: "Conferma ordine #4821"
                  html_body: "<p>Il tuo ordine #4821 è confermato.</p>"
              conBrandAsserito:
                summary: Con brand_id come asserzione (facoltativo)
                description: >-
                  `brand_id` non sceglie il brand: conferma quello della chiave.
                  Se non coincide la richiesta è rifiutata, ed è esattamente il
                  caso in cui un'integrazione sta usando la chiave sbagliata.
                value:
                  from: "notifiche@cliente-esempio.com"
                  to: "utente@destinatario.com"
                  subject: "Conferma ordine #4821"
                  html_body: "<p>Il tuo ordine #4821 è confermato.</p>"
                  brand_id: "3f2b8c1d-9a4e-4c7b-8e5f-1a2b3c4d5e6f"
              soloTesto:
                summary: Solo testo (la parte HTML viene derivata)
                description: >-
                  Il destinatario riceve comunque entrambe le parti: il testo
                  così com'è e un HTML neutro generato da esso, con l'URL reso
                  cliccabile.
                value:
                  from: "notifiche@cliente-esempio.com"
                  to: "utente@destinatario.com"
                  subject: "Conferma ordine #4821"
                  text_body: "Il tuo ordine #4821 è confermato.


                    Dettaglio: https://negozio.example/ordini/4821"
              daTemplate:
                summary: Da template con variabili
                value:
                  from: "notifiche@cliente-esempio.com"
                  to: "utente@destinatario.com"
                  template_id: "5f1d6c2e-3b4a-4f0e-9c1a-2d8e7f6a1b3c"
                  variables:
                    nome_cliente: "Maria"
                    numero_ordine: "4821"
      responses:
        "202":
          description: >-
            Accodata per l'invio, con entrambe le parti del messaggio popolate
            (quella non fornita è derivata al momento dell'invio). Identica sia
            che il destinatario venga effettivamente recapitato sia che sia
            silenziosamente scartato per suppression list (ADR-0017) — vedi
            descrizione sopra.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
              example:
                status: queued
                email_log_id: "9c4e2f1a-7d3b-4a5e-8f6c-1b2a3d4e5f6a"
        "400":
          description: Richiesta non valida.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                invalidRequest:
                  summary: Payload malformato o campi incompatibili/mancanti
                  description: >-
                    `from`/`to` mancanti, corpo JSON non valido, oppure
                    `template_id` presente insieme a `subject`/`html_body`/
                    `text_body` (mutuamente esclusivi), oppure nessuna delle due
                    modalità è completa (né contenuto diretto valido né
                    `template_id`).
                  value:
                    error:
                      code: invalid_request
                      message: "from e to sono obbligatori."
                brandRequiredOnSession:
                  summary: Autenticazione via sessione senza brand_id
                  description: >-
                    Solo sul percorso a sessione cookie: con una API key il
                    brand arriva dalla chiave e questo caso non esiste.
                  value:
                    error:
                      code: invalid_request
                      message: "brand_id è obbligatorio: indica il brand da cui parte questo invio."
                invalidFrom:
                  summary: from non è un indirizzo email
                  value:
                    error:
                      code: invalid_request
                      message: "from non è un indirizzo email valido."
                invalidFromName:
                  summary: from_name non valido
                  description: >-
                    `from_name` è più lungo di 78 caratteri, contiene `@`, `<`,
                    `>`, virgolette, un a-capo o un indirizzo web.
                  value:
                    error:
                      code: invalid_from_name
                      message: "from_name non è valido: da 1 a 78 caratteri, senza «@», «<», «>»,
                        virgolette, a-capo o indirizzi web."
                invalidReplyTo:
                  summary: reply_to non valido
                  description: >-
                    `reply_to` non è un solo indirizzo email valido (nome
                    visualizzato, più indirizzi, caratteri non ammessi), oppure
                    è un indirizzo del dominio di mailronix.
                  value:
                    error:
                      code: invalid_reply_to
                      message: "reply_to non è un indirizzo email valido: serve un solo indirizzo,
                        senza nome e senza spazi."
                invalidLanguage:
                  summary: language non valida
                  description: >-
                    `language` non è una tra `it`, `en`, `es`, `de`, `fr`.
                  value:
                    error:
                      code: invalid_language
                      message: "language deve essere una tra it, en, es, de, fr."
                tooManyAttachments:
                  summary: Più di 5 allegati
                  value:
                    error:
                      code: too_many_attachments
                      message: "Troppi allegati: al massimo 5 per messaggio."
                invalidAttachment:
                  summary: Allegato non valido
                  description: >-
                    Manca `filename`, `content_type` o `content`, il contenuto
                    non è base64 standard, oppure è vuoto.
                  value:
                    error:
                      code: invalid_attachment
                      message: "Allegato non valido: servono filename, content_type e content in
                        base64 standard, non vuoti."
                attachmentTypeNotAllowed:
                  summary: Tipo di allegato non ammesso
                  description: >-
                    Il tipo non è nell'elenco chiuso, oppure il contenuto o
                    l'estensione del nome non corrispondono al tipo dichiarato.
                  value:
                    error:
                      code: attachment_type_not_allowed
                      message: "Tipo di allegato non ammesso. Sono ammessi application/pdf, image/png,
                        image/jpeg, image/gif, text/plain, text/csv e
                        text/calendar; il contenuto e l'estensione del nome
                        devono corrispondere al tipo dichiarato."
                attachmentsNotAllowedForMarketing:
                  summary: Allegati su un messaggio marketing
                  value:
                    error:
                      code: attachments_not_allowed_for_marketing
                      message: "Un messaggio marketing non può avere allegati: gli allegati sono
                        ammessi solo sui messaggi transazionali."
                missingTemplateVariable:
                  summary: Variabile del template non fornita
                  value:
                    error:
                      code: missing_template_variable
                      message: "Il template referenzia una variabile non fornita."
        "401":
          description: >-
            Nessuna autenticazione valida — né sessione cookie né API key
            (mancante, malformata, sconosciuta o revocata).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: unauthenticated
                  message: "Autenticazione richiesta."
        "403":
          description: Richiesta rifiutata da un gate applicativo.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                fromAddressNotOfBrand:
                  summary: Il mittente non è sul dominio del brand dell'invio
                  description: >-
                    Il dominio di `from` deve essere il dominio del brand da cui
                    parte l'invio, o un suo sottodominio verificato. Un dominio
                    verificato che appartiene a un ALTRO brand dello stesso
                    account è rifiutato: il tenant SES si deriva dal dominio del
                    brand e DKIM firma quello del mittente, e i due non devono
                    divergere. Il dominio del brand è su `GET /brands`.
                  value:
                    error:
                      code: from_address_not_of_brand
                      message: "Il dominio del mittente non è quello del brand da cui parte questo
                        invio: un brand invia dal proprio dominio (o da un suo
                        sottodominio verificato). Vedi il dominio del brand su
                        GET /brands."
                domainNotVerified:
                  summary: Dominio del mittente non verificato
                  value:
                    error:
                      code: domain_not_verified
                      message: "Il dominio del mittente non è verificato per questo tenant."
                tenantSuspended:
                  summary: Tenant sospeso
                  value:
                    error:
                      code: tenant_suspended
                      message: "Il tenant è sospeso."
                freeDailyRecipientsExceeded:
                  summary: Limite giornaliero di destinatari unici del piano Free raggiunto
                  value:
                    error:
                      code: free_daily_recipients_exceeded
                      message: "Limite giornaliero di destinatari unici del piano Free raggiunto."
                planLimitExceeded:
                  summary: Limite mensile del piano superato
                  value:
                    error:
                      code: plan_limit_exceeded
                      message: "Limite mensile del piano superato."
                replyToNotAllowed:
                  summary: reply_to non consentito dal brand
                  description: >-
                    La richiesta porta `reply_to` ma il brand non consente il
                    Reply-To per richiesta. Si attiva dalla console, con la
                    sessione.
                  value:
                    error:
                      code: reply_to_not_allowed
                      message: "Questo brand non consente di indicare un reply_to nella richiesta:
                        attivalo nelle impostazioni del brand in console."
                brandMismatch:
                  summary: brand_id diverso da quello della chiave
                  value:
                    error:
                      code: brand_mismatch
                      message: "Il brand dichiarato non è quello a cui questa chiave è legata: la
                        chiave scrive sotto un brand solo, e il campo brand_id
                        può solo confermarlo."
        "404":
          description: >-
            `template_id` o `brand_id` inesistente, oppure appartenente a un
            altro tenant — stessa risposta in entrambi i casi, per non rivelare
            l'esistenza di template o brand altrui.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: template_not_found
                  message: "Template non trovato."
        "409":
          description: >-
            Uno stato del brand che il tenant può sciogliere da sé.
            `brand_archived`: il brand è archiviato e in scrittura è rifiutato —
            riattivalo dalla console, oppure, se era la chiave a esserne legata,
            creane una nuova sul brand giusto. `brand_sender_not_configured`: il
            brand non ha ancora un indirizzo mittente, quindi non è ancora un
            mittente e non può inviare in proprio nome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                brandArchived:
                  summary: Brand archiviato
                  value:
                    error:
                      code: brand_archived
                      message: "Il brand è archiviato: riattivalo prima di usarlo."
                brandSenderNotConfigured:
                  summary: Il brand non ha ancora un indirizzo mittente
                  value:
                    error:
                      code: brand_sender_not_configured
                      message: "Questo brand non ha ancora un indirizzo mittente: impostane uno, su un
                        dominio verificato, prima di inviare in suo nome."
        "413":
          description: >-
            Corpo o allegati troppo grandi. `attachments_too_large`: gli
            allegati superano 7 MB in totale, misurati dopo la decodifica
            base64, oppure l'email completa (allegati codificati in base64, che
            pesano circa un terzo in più del file, più il corpo) supererebbe 10
            MB. `payload_too_large`: il corpo supera il limite di lettura (12
            MiB con allegati; 64 KiB senza).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                attachmentsTooLarge:
                  summary: Allegati oltre 7 MB in totale, o email oltre 10 MB
                  value:
                    error:
                      code: attachments_too_large
                      message: "Gli allegati superano 7 MB in totale (dimensione dopo la decodifica
                        base64)."
                payloadTooLarge:
                  summary: Corpo della richiesta troppo grande
                  value:
                    error:
                      code: payload_too_large
                      message: "Corpo della richiesta troppo grande."
        "422":
          description: >-
            `idempotency_key_body_mismatch` — l'header `Idempotency-Key`
            presentato è già associato a una richiesta precedente con un corpo
            diverso. Usa un valore nuovo per una richiesta diversa.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: idempotency_key_body_mismatch
                  message: "Questa Idempotency-Key è già stata usata con un corpo diverso."
        "429":
          description: >-
            `rate_limited`: rate limit per chiave API superato. Applicabile solo
            quando l'autenticazione avviene via `Authorization: Bearer` — il
            limite è per chiave, non per IP, e non si applica all'autenticazione
            via sessione cookie. `attachments_backlog`: il tuo account ha già in
            attesa di invio più di 70 MB di allegati; la richiesta è corretta e
            nulla è stato scritto. Riprova dopo qualche secondo (header
            `Retry-After`), con la stessa `Idempotency-Key` e gli stessi byte.
            `server_busy`: il server sta già trattando il massimo di richieste
            con corpo grande (oltre 64 KiB, quindi di norma quelle con
            allegati); la richiesta è corretta, riprova dopo qualche secondo.
            Non riguarda le richieste normali.
          headers:
            Retry-After:
              description: Secondi da attendere prima di riprovare (solo con
                `attachments_backlog` e `server_busy`).
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                rateLimited:
                  summary: Rate limit per chiave
                  value:
                    error:
                      code: rate_limited
                      message: "Troppe richieste, riprova più tardi."
                serverBusy:
                  summary: Troppe richieste con corpo grande in corso
                  value:
                    error:
                      code: server_busy
                      message: "Troppe richieste con corpo grande in corso: riprova fra qualche
                        secondo."
                attachmentsBacklog:
                  summary: Troppi allegati in attesa di invio
                  value:
                    error:
                      code: attachments_backlog
                      message: "Hai troppi allegati in attesa di invio: riprova fra qualche secondo,
                        quando quelli già accodati saranno partiti."
        "500":
          description: Errore interno.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: internal_error
                  message: "Errore interno."
        "503":
          description: >-
            Verifica della chiave API o dello stato del tenant temporaneamente
            non disponibile. Applicabile solo all'autenticazione via API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: auth_unavailable
                  message: "Verifica della chiave non disponibile."
  /email/{id}:
    get:
      operationId: getEmailStatus
      summary: Leggi lo stato del tuo invio
      description: |
        Restituisce lo stato dell'invio identificato da `email_log_id` —
        il valore che `POST /email/send` ti ha restituito. Non esiste un
        modo di scoprire l'id di un invio che non è il tuo: un `id`
        inesistente o appartenente a un altro brand riceve lo stesso
        `404`, senza distinzione.

        ## Gli stati, e perché sono meno di quelli interni

        | `status` | significato |
        |---|---|
        | `queued` | accodato, non ancora concluso |
        | `sent` | consegnato con successo |
        | `complained` | consegnato, il destinatario ha segnalato come spam |
        | `undelivered` | non arrivato |

        **`undelivered` copre sia un bounce reale sia un indirizzo già
        soppresso per bounce/complaint pregressi (ADR-0017), e la
        distinzione è deliberata, non un'approssimazione provvisoria.**
        Un indirizzo soppresso non genera alcun invio reale: se questo
        endpoint lo segnalasse come caso distinto, chiunque potrebbe
        mandare `POST /email/send` a un indirizzo qualsiasi e usare
        questo endpoint per scoprire **gratis** se è già soppresso per il
        tuo brand — lo stesso oracolo che `POST /email/send` chiude
        restituendo sempre lo stesso `202`, spostato di una chiamata.
        Con `undelivered` unico sai comunque che il messaggio non è
        arrivato — la domanda a cui questo endpoint risponde.

        **Il valore da solo non basta: anche il momento in cui questo
        endpoint smette di rispondere `queued` è parte del presidio.**
        Un indirizzo soppresso si risolve nell'istante stesso dell'invio;
        un bounce vero richiede il giro reale (coda, invio, notifica del
        provider) e non può concludersi altrettanto in fretta. Per questo
        una richiesta fatta a meno di qualche secondo dall'invio risponde
        sempre `queued`, **anche se l'esito è già determinato**: senza
        questo, la rapidità della risposta rivelerebbe la stessa
        informazione che il valore unico `undelivered` nasconde.
      security:
        - apiKey: []
        - sessionCookie: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Il valore `email_log_id` ricevuto da `POST /email/send`.
      responses:
        "200":
          description: Stato dell'invio.
          content:
            application/json:
              schema:
                type: object
                required: [ email_log_id, status ]
                properties:
                  email_log_id:
                    type: string
                    format: uuid
                  status:
                    type: string
                    enum: [ queued, sent, complained, undelivered ]
              example:
                email_log_id: "9c4e2f1a-7d3b-4a5e-8f6c-1b2a3d4e5f6a"
                status: sent
        "401":
          description: Credenziale assente, non valida o revocata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: "`capability_not_granted` — serve `email:read`; `api_key_disabled`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: >-
            L'id non esiste, oppure appartiene a un brand diverso da quello
            della chiave — stessa risposta in entrambi i casi.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit per chiave API superato.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /contacts:
    post:
      operationId: createContact
      summary: Crea un contatto e avvia la conferma double opt-in
      description: |
        Crea un contatto **non confermato** sotto il brand della chiave e
        gli invia la richiesta di conferma dell'iscrizione.

        ## Il consenso non si dichiara

        Questo endpoint **non consente in alcun modo di creare un contatto
        già confermato né di dichiarare un consenso raccolto altrove**. Il
        contatto nasce in attesa, la richiesta di conferma parte da noi, e
        ciò che vale come consenso è il click del destinatario. È lo
        stesso identico flusso della console: cambia come autentichi la
        chiamata, non la natura dell'atto.

        Finché il contatto non conferma non riceve nessun messaggio
        marketing — e un `POST /email/send` con `"marketing": true` verso
        quell'indirizzo è rifiutato.

        ## La risposta non dice cosa c'era prima

        La risposta è **la stessa** — `204`, corpo vuoto — che l'indirizzo
        sia nuovo, che fosse già un tuo contatto, o che avesse revocato il
        consenso presso questo brand. Non è una semplificazione: tre
        risposte distinte permetterebbero a chi possiede la chiave di
        verificare, un indirizzo per volta, se una data persona è tua
        cliente e se ti ha detto di no.

        Ne segue che questa risposta **non restituisce alcun
        identificativo**, e che gli altri endpoint su questa superficie
        indirizzano un contatto per `(brand, email)`.

        Se l'indirizzo aveva revocato presso questo brand, il contatto
        viene creato **inerte**: nessuna richiesta di conferma parte, e
        nessuna operazione tua può riattivarlo — solo una nuova iscrizione
        della persona stessa.
      security:
        - apiKey: []
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateContactRequest"
      responses:
        "204":
          description: >-
            Presa in carico. Stesso esito per indirizzo nuovo, contatto già
            esistente e contatto che aveva revocato.
        "201":
          description: >-
            Solo sul percorso a **sessione cookie**, che continua a distinguere
            i tre casi perché un operatore che aggiunge un indirizzo ha bisogno
            di sapere che c'era già.
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Corpo non valido, indirizzo non valido, `language` non valida
            (`invalid_language`), o brand mancante sul percorso a sessione.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Credenziale assente, non valida o revocata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: >-
            `capability_not_granted` — la chiave non ha `contacts:write`;
            `brand_mismatch` — `brand_id` dichiarato diverso da quello della
            chiave; `brand_marketing_suspended` — il canale del brand è sospeso,
            e non si crea un contatto la cui conferma non partirebbe;
            `api_key_disabled`; `tenant_suspended`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: >-
            `brand_archived` — riattiva il brand prima di usarlo;
            `brand_sender_not_configured` — il brand non ha ancora un indirizzo
            mittente: dichiara dominio e indirizzo mittente sul brand, perché la
            richiesta di conferma parte da lì; `no_verified_domain` — verifica
            il dominio DEL BRAND: la richiesta di conferma parte dall'indirizzo
            mittente di quel brand, non da un dominio qualunque del tuo account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Limite di richieste superato per questa chiave.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /contacts/revoke:
    post:
      operationId: revokeContactConsent
      summary: Registra la disiscrizione di un contatto
      description: |
        Revoca il consenso di un contatto del brand della chiave. È
        l'endpoint da chiamare quando una persona toglie il consenso nella
        **tua** applicazione.

        **Chiamalo tu, e non usare la cancellazione al suo posto.** Una
        cancellazione non registra alcuna revoca: la sincronizzazione
        successiva ricrea il contatto, e gli parte una nuova richiesta di
        conferma — cioè scriviamo a una persona che si era appena
        disiscritta.

        ## La revoca non si annulla

        Non esiste alcun modo, per te o per noi, di annullare una revoca:
        il contatto potrà tornare a ricevere comunicazioni solo con una
        **nuova iscrizione fatta da lui**. Per questa ragione il limite di
        richieste su questa operazione non ti rallenta e basta: superarlo
        **disabilita la chiave**, e riattivarla richiede una persona che
        entri in console.

        La risposta è la stessa che l'indirizzo fosse un contatto o no.

        ## Puoi chiamarlo prima di averci mandato il contatto

        Se l'indirizzo **non è ancora** un tuo contatto, la revoca viene
        registrata lo stesso. Da quel momento quell'indirizzo non riceve
        nulla per quel brand: se in seguito lo crei con `POST /contacts`,
        il contatto nasce inerte, **nessuna richiesta di conferma parte**,
        e nessuna campagna lo raggiunge.

        È il caso della persona che toglie il consenso nella tua
        applicazione prima che tu ci abbia mai sincronizzato quel
        contatto — l'ordine è il tuo, non il nostro.

        ⚠️ **Non usarlo su indirizzi che non hai motivo di negare.** La
        riga scritta è definitiva anche per te: non si cancella, non si
        corregge, e non esiste un'operazione inversa. Un indirizzo revocato
        per errore torna a ricevere comunicazioni **solo** con una nuova
        iscrizione fatta dalla persona.

        ## Ogni revoca porta il nome della chiave che l'ha scritta

        Le revoche registrate da questo endpoint sono **elencate in
        console**, con la data, l'indirizzo e il **prefisso della chiave**
        che le ha registrate — anche quelle su indirizzi che non erano
        contatti.

        Non è un dettaglio di interfaccia ed è bene che tu lo sappia
        scrivendo l'integrazione: se una chiave compromessa comincia a
        negare indirizzi, chi amministra l'account lo vede e sa **quale
        chiave** disabilitare. Le revoche registrate prima dell'introduzione
        di questa tracciatura restano senza attribuzione: non è stata
        indovinata a posteriori.
      security:
        - apiKey: []
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactAddressRequest"
      responses:
        "204":
          description: Presa in carico. Stesso esito che l'indirizzo fosse un contatto o no.
        "400":
          description: Corpo o indirizzo non valido.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Credenziale assente, non valida o revocata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: >-
            `capability_not_granted` — serve `contacts:unsubscribe`;
            `brand_mismatch`; `api_key_disabled` — il limite sulle operazioni
            irreversibili ha spento questa chiave, e ritentare non la riattiva.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: "`brand_archived`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /contacts/delete:
    post:
      operationId: deleteContact
      summary: Elimina un contatto
      description: |
        Elimina il contatto del brand della chiave.

        **Non è il modo di dire «si è disiscritto».** Per quello esiste
        `POST /contacts/revoke`, e la differenza è sostanziale: una
        cancellazione rimuove il dato ma **non registra alcuna revoca**,
        quindi non impedisce che un caricamento successivo ricrei il
        contatto e faccia ripartire la richiesta di conferma.

        Una revoca già registrata, invece, **sopravvive** alla
        cancellazione: se hai revocato prima di cancellare, ricreare quel
        contatto lo fa nascere inerte e nessun messaggio parte. È la
        ragione per cui `contacts:delete` non è concedibile senza
        `contacts:unsubscribe`.

        `POST` e non `DELETE` perché il contatto si indirizza per `(brand,
        email)` e l'indirizzo viaggia nel corpo: metterlo in query string
        lo scriverebbe nei log di ogni intermediario.

        Un `POST` è **verbo di metodo**, non un'operazione idempotente
        diversa: chiamarlo due volte sullo stesso indirizzo produce lo
        stesso esito.
      security:
        - apiKey: []
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactAddressRequest"
      responses:
        "204":
          description: Presa in carico. Stesso esito che l'indirizzo fosse un contatto o no.
        "400":
          description: Corpo o indirizzo non valido.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Credenziale assente, non valida o revocata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: >-
            `capability_not_granted` — serve `contacts:delete`, che richiede
            anche `contacts:unsubscribe`; `brand_mismatch`; `api_key_disabled`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: "`brand_archived`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /contacts/revocations:
    get:
      operationId: listBrandRevocations
      summary: Scorri le revoche del tuo brand
      description: |
        Restituisce, in ordine cronologico di revoca, le disiscrizioni
        registrate per il brand a cui questa chiave è legata — **tutte**,
        non solo quelle che hai registrato tu: il click sul link di
        disiscrizione di una nostra email, l'azione di chi amministra
        l'account in console, e le tue chiamate a `POST /contacts/revoke`.

        È l'endpoint da usare per **riconciliare**: sapere delle revoche
        avvenute fuori dalla tua integrazione, e smettere di scrivere a
        chi ha detto di no.

        ## Il cursore

        Il campo `next_cursor` è una stringa **opaca**: non interpretarla,
        non costruirla, non derivarne una data. Conservala e rimandala in
        `since` alla richiesta successiva.

        Il cursore è **stabile**: due revoche registrate nello stesso
        istante non fanno saltare né ripetere una riga. È la ragione per
        cui non è una data — con una data, due disiscrizioni scritte nella
        stessa transazione (succede: un'importazione, una cancellazione a
        cascata) starebbero sullo stesso valore, e la seconda non ti
        arriverebbe mai.

        Il cursore è **legato alla chiave che lo ha ricevuto**: presentato
        da una chiave di un altro brand riceve `400 invalid_cursor`. Non è
        un token di autorizzazione e non ti dà accesso a nulla — è una
        posizione dentro un insieme che sei già autorizzato a leggere per
        intero.

        ## Come si sincronizza

        1. prima volta: chiama senza `since` e scorri finché
           `has_more` è `false`;
        2. conserva l'ultimo `next_cursor`;
        3. dalla volta dopo: chiama con `since=<cursore conservato>`.

        **La condizione di fine è `has_more == false`, non
        `next_cursor == null`.** Questa non è una paginazione ma una coda
        che si segue: `while next_cursor != null` — l'abitudine che si
        porta dalla paginazione classica — qui non termina mai.

        A coda finita `next_cursor` **non è nullo**: è la tua posizione, e
        resta valida. Una chiamata che non trova nulla di nuovo
        restituisce `items` vuoto, `has_more` a `false` e lo stesso
        cursore che le hai dato — non devi ricominciare da capo.

        `next_cursor` è nullo in **un caso solo**: la prima chiamata su un
        brand che non ha ancora alcuna revoca — cioè pagina vuota *e*
        nessun `since` in ingresso. Non c'è ancora una posizione da
        ricordare, e la chiamata successiva va fatta di nuovo senza
        `since`. Non esiste il caso «avevo un cursore e ricevo `null`»:
        con un `since` valido in ingresso il cursore torna sempre
        valorizzato, anche a pagina vuota.

        ## Il campo `revoked_at`

        È l'istante in cui **la persona ha revocato**, non quello in cui
        tu lo leggi né quello in cui noi lo abbiamo registrato una seconda
        volta. Una revoca ripetuta sullo stesso indirizzo non sposta
        questa data: la riga conserva l'istante e l'origine della **prima**
        — che è anche il motivo per cui richiamare `POST /contacts/revoke`
        su un indirizzo già revocato non rompe niente.

        ## Il campo `origin`

        Ti dice **chi ha agito**, non quale credenziale:

        | valore | significato |
        |---|---|
        | `unsubscribe_link` | la persona ha usato il link di disiscrizione di una nostra email |
        | `console` | chi amministra l'account ha agito dalla console |
        | `api_key` | una tua integrazione ha chiamato `POST /contacts/revoke` |
        | `undeclared` | l'origine non è stata dichiarata |
        | `unknown` | la revoca è anteriore all'introduzione di questo campo |

        `api_key` non dice **quale** chiave: quante integrazioni hai e
        come si chiamano non è un'informazione che una di esse debba
        apprendere. Se ti serve, è in console.

        ## Cosa questo endpoint non fa

        Non ti dice **quante** revoche esistono: non c'è un totale, e non
        c'è modo di ottenerlo. Scorri, oppure no.

        Non ti dice se un indirizzo revocato sia **anche** un tuo contatto
        attuale. Le due cose sono indipendenti: la memoria di una revoca
        sopravvive alla cancellazione del contatto — è ciò che impedisce a
        un ciclo «cancella e ricrea» di far tornare inviabile chi si era
        disiscritto — quindi in questa coda puoi trovare indirizzi che nel
        tuo elenco contatti non esistono più. È corretto che sia così, e
        vanno trattati come tutti gli altri: a quegli indirizzi non si
        scrive.

        Non attraversa i brand: vedi le revoche del brand di questa
        chiave, e nient'altro. Con più brand servono più chiavi.
      security:
        - apiKey: []
      parameters:
        - name: since
          in: query
          required: false
          schema:
            type: string
          description: >-
            Il `next_cursor` ricevuto dalla richiesta precedente. Omettilo per
            partire dall'inizio della coda. Stringa opaca: un valore non emesso
            da noi, o emesso per una chiave di un altro brand, riceve `400
            invalid_cursor`.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 100
            maximum: 500
          description: >-
            Quante revoche per pagina. Un valore superiore a 500 non è un
            errore: viene semplicemente servito come 500.
      responses:
        "200":
          description: Una pagina della coda.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RevocationFeedPage"
        "400":
          description: >-
            `invalid_cursor` — il cursore non è valido per questa chiave;
            riparti senza `since`. `invalid_request` — `limit` non è un intero
            positivo.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Credenziale assente, non valida o revocata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: >-
            `capability_not_granted` — serve `revocations:read`;
            `api_key_required` — questa lettura si autentica con una API key, e
            da console c'è l'elenco delle revoche; `api_key_disabled`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: "`brand_archived`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: >-
            `rate_limited` — troppe richieste in questa finestra. È un rifiuto
            TEMPORANEO e non spegne la chiave: attendi e riprendi dallo stesso
            cursore.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
webhooks:
  mailronixEvent:
    post:
      operationId: receiveMailronixEvent
      summary: Un evento del tuo account, consegnato al tuo endpoint
      description: |
        mailronix invia questa richiesta **al tuo server**, all'indirizzo
        HTTPS registrato in console. Il tuo ricevitore la implementa; non è
        un endpoint di mailronix.

        ## Rispondi in fretta, con un 2xx

        Qualunque risposta `2xx` conferma la consegna. Tutto il resto è un
        fallimento e provoca un nuovo tentativo: `3xx` compresi — **i
        redirect non vengono seguiti**. Il tempo massimo di un tentativo è
        10 secondi. Del corpo della risposta leggiamo al più 1 KB e non lo
        conserviamo oltre un estratto troncato. Accetta l'evento, scrivilo
        in una coda tua e rispondi; l'elaborazione vera falla dopo.

        ## Verifica la firma PRIMA di fidarti del corpo

        Ogni richiesta porta l'intestazione `Mailronix-Signature`:

        ```
        Mailronix-Signature: t=1761141791,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
        ```

        1. Leggi `t` (secondi Unix) e tutti i valori `v1`.
        2. Calcola `HMAC-SHA256(segreto, t + "." + corpo_grezzo)`, in
           esadecimale minuscolo. Usa il **corpo grezzo, byte per byte**,
           così come è arrivato: non rileggere e riserializzare il JSON.
        3. Confronta con ciascun `v1` **a tempo costante**. Basta che uno
           corrisponda.
        4. Rifiuta se `t` dista più di **5 minuti** dall'ora del tuo
           server: protegge dai replay. `t` è l'istante di *questo
           tentativo*, non della creazione dell'evento, quindi un nuovo
           tentativo dopo ore passa comunque la finestra.

        Il segreto (`whsec_…`) ti viene mostrato **una sola volta**, alla
        creazione dell'endpoint o alla rotazione. Dopo una rotazione senza
        revoca immediata, per 24 ore ogni richiesta porta **due** valori
        `v1`, quello del segreto nuovo e quello del precedente: aggiorna il
        tuo ricevitore senza interruzioni, verificando contro entrambi i
        segreti finché non hai finito.

        ```python
        import hmac, hashlib, time

        def verifica(intestazione, corpo: bytes, segreto: bytes, tolleranza=300):
            parti = [p.strip().split("=", 1) for p in intestazione.split(",")]
            t = next(v for k, v in parti if k == "t")
            firme = [v for k, v in parti if k == "v1"]
            if abs(time.time() - int(t)) > tolleranza:
                return False
            atteso = hmac.new(segreto, t.encode() + b"." + corpo, hashlib.sha256).hexdigest()
            return any(hmac.compare_digest(atteso, f) for f in firme)
        ```

        ## Idempotenza e ordine

        Ogni evento ha un `id` stabile (`evt_…`) e lo ritrovi
        nell'intestazione `Mailronix-Event-Id`. **Lo stesso evento può
        arrivarti più volte** — dopo un tentativo andato a vuoto, un
        rinvio dalla console, un ripristino dopo un'interruzione nostra —
        quindi il tuo ricevitore deve essere idempotente su `id`.
        **L'ordine non è garantito**: usa `created_at` per ordinare.

        ## Tentativi

        Un tentativo fallito viene ripetuto dopo 10 secondi, 1 minuto, 5
        minuti, 30 minuti, 2 ore, 6 ore, 12 ore, 24 ore e 48 ore: **dieci
        tentativi in tutto, in circa quattro giorni**. Poi la consegna
        risulta fallita e puoi rinviarla dalla console. Se una consegna
        esaurisce i tentativi e l'endpoint non ne ha avuta nessuna riuscita
        nelle ultime 72 ore, l'endpoint viene **disattivato** e il titolare
        dell'account riceve un avviso; si riattiva dalla console. Gli
        eventi si conservano 30 giorni.

        ## Che cosa non facciamo

        Non seguiamo redirect, non usiamo proxy, non consegniamo a indirizzi
        che si risolvono verso reti private, loopback o link-local (verifica
        rifatta **a ogni tentativo**, non solo alla registrazione) e non
        inviamo mai il corpo dell'email.
      parameters:
        - name: Mailronix-Signature
          in: header
          required: true
          description: "`t=<unix>,v1=<hex>[,v1=<hex>]` — vedi sopra."
          schema:
            type: string
        - name: Mailronix-Event-Id
          in: header
          required: true
          description: "Lo stesso valore del campo `id` del corpo."
          schema:
            type: string
        - name: Mailronix-Delivery-Id
          in: header
          required: true
          description: >-
            Identifica **questa** consegna. Serve a correlare i log, non alla
            deduplica: per quella usa l'`id` dell'evento.
          schema:
            type: string
        - name: Mailronix-Event-Type
          in: header
          required: true
          description: "Lo stesso valore del campo `type` del corpo."
          schema:
            $ref: "#/components/schemas/WebhookEventType"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEvent"
            examples:
              emailBounced:
                summary: Un rifiuto permanente
                value:
                  id: evt_3f1a9c2e7b6d4e8f9a0b1c2d3e4f5a6b
                  type: email.bounced
                  api_version: "2026-10-22"
                  created_at: "2026-10-22T14:03:11Z"
                  brand_id: 6f1d2c0e-8b1a-4c53-9a77-0d6a7b3a2c11
                  data:
                    email_log_id: 0b7c1e52-3f0a-4d3e-8a8e-5b1f2c9d4e60
                    recipient: persona@example.com
                    category: transactional
                    bounce_type: permanent
              contactUnsubscribed:
                summary: Una disiscrizione di un indirizzo che non è un contatto
                value:
                  id: evt_9c4e2a1b7d3f4a6e8b0c1d2e3f4a5b6c
                  type: contact.unsubscribed
                  api_version: "2026-10-22"
                  created_at: "2026-10-22T14:07:42Z"
                  brand_id: 6f1d2c0e-8b1a-4c53-9a77-0d6a7b3a2c11
                  data:
                    contact_id: null
                    email: persona@example.com
      responses:
        "2XX":
          description: Evento ricevuto. Il corpo della risposta viene ignorato.
        "3XX":
          description: >-
            Considerato un fallimento: il redirect non viene seguito e la
            consegna sarà ritentata.
        "4XX":
          description: >-
            Fallimento, ritentato secondo la tabella dei tentativi. Non esiste
            un codice che significhi «non ritentare»: se non vuoi più gli
            eventi, disattiva l'endpoint dalla console.
        "5XX":
          description: Fallimento, ritentato.
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >-
        API key generata dalla console (`POST /api-keys`, fuori perimetro di
        questo documento), formato `mrx_live_<segreto>`. Mostrata in chiaro una
        sola volta al momento della creazione. Autentica esclusivamente gli
        endpoint descritti in questo documento, e solo quelli per cui porta la
        capacità corrispondente — nessun altro endpoint del backend accetta
        questo meccanismo.

        **Una sola lettura, e va concessa esplicitamente.** `GET
        /contacts/revocations` — la coda delle revoche del tuo brand — è l'unico
        endpoint di questa API che ti restituisce dati che non gli hai dato tu,
        e richiede la capacità `revocations:read`, che non è implicita in
        nessun'altra.

        Tutto il resto una chiave non può interrogarlo: né i tuoi contatti, né i
        tuoi brand, né sé stessa. Non esiste alcun endpoint che dica a una
        chiave quale brand ha, quali capacità porta, quando scade o se è mai
        stata usata. Ciò che la tua integrazione deve sapere di sé lo apprende
        dall'ESITO delle operazioni che compie — i codici di errore sono parte
        del contratto e sono stabili.

        ⚠️ **Tratta una chiave con `revocations:read` come un dato personale in
        movimento.** Senza quella capacità, una chiave finita in un repository
        pubblico non può annunciare a chi appartiene; con quella capacità, può
        scaricare gli indirizzi di chi si è disiscritto dal tuo brand. Concedila
        solo alle integrazioni che devono riconciliare, e ritirala quando
        smettono.

        Ogni chiave è legata a **un** brand, scelto alla creazione e non più
        modificabile: è il brand sotto cui tutti i suoi invii partono. Con più
        brand si creano più chiavi.
    sessionCookie:
      type: apiKey
      in: cookie
      name: mailronix_session
      description: >-
        Sessione browser autenticata della console — usarla per testare questo
        endpoint dagli strumenti di sviluppo del browser mentre si è loggati;
        un'integrazione server-to-server userà normalmente `apiKey`.
  schemas:
    WebhookEventType:
      type: string
      description: >-
        Il catalogo sottoscrivibile. Non esistono eventi di apertura.
        `email.clicked` esiste solo per i brand che hanno acceso il tracciamento
        dei click, e solo per i destinatari che hanno acconsentito (vedi
        `WebhookClickData`). `webhook.test` lo inviamo noi quando provi un
        endpoint dalla console: stessa busta, nessun dato di destinatari, non è
        sottoscrivibile.
      enum:
        - email.sent
        - email.delivered
        - email.bounced
        - email.complained
        - email.failed
        - email.suppressed
        - email.clicked
        - contact.confirmed
        - contact.unsubscribed
        - webhook.test
    WebhookEvent:
      type: object
      required: [ id, type, api_version, created_at, brand_id, data ]
      properties:
        id:
          type: string
          description: >-
            Stabile per evento: lo stesso valore a ogni tentativo e a ogni
            rinvio. Chiave di idempotenza del tuo ricevitore.
          examples: [ "evt_3f1a9c2e7b6d4e8f9a0b1c2d3e4f5a6b" ]
        type:
          $ref: "#/components/schemas/WebhookEventType"
        api_version:
          type: string
          description: >-
            La versione del payload. L'aggiunta di campi non la cambia, quindi
            ignora i campi che non conosci.
          examples: [ "2026-10-22" ]
        created_at:
          type: string
          format: date-time
          description: Quando l'evento è stato registrato, in UTC.
        brand_id:
          type: [ string, "null" ]
          format: uuid
          description: >-
            Il brand a cui appartiene l'evento (un endpoint con filtro per brand
            non riceve gli eventi degli altri). `null` solo in `webhook.test`.
        data:
          oneOf:
            - $ref: "#/components/schemas/WebhookEmailData"
            - $ref: "#/components/schemas/WebhookClickData"
            - $ref: "#/components/schemas/WebhookContactData"
            - $ref: "#/components/schemas/WebhookTestData"
          description: >-
            Il contenuto dipende da `type`: `email.clicked` porta
            `WebhookClickData`, gli altri `email.*` portano `WebhookEmailData`,
            `contact.*` porta `WebhookContactData`, `webhook.test` porta
            `WebhookTestData`.
    WebhookEmailData:
      type: object
      required: [ email_log_id, recipient, category ]
      properties:
        email_log_id:
          type: string
          format: uuid
          description: L'identificatore dell'invio a cui l'evento si riferisce.
        recipient:
          type: string
          format: email
        category:
          type: string
          description: La categoria con cui l'email è stata inviata.
        bounce_type:
          type: string
          enum: [ permanent, transient, undetermined ]
          description: >-
            Solo in `email.bounced`. Un rifiuto annunciato come `transient` che
            poi viene riclassificato `permanent` produce un **secondo**
            `email.bounced` con `permanent`.
    WebhookClickData:
      type: object
      required: [ click_id, campaign_id, link_id, contact_id, recipient, destination_host ]
      description: >-
        Il contenuto di `email.clicked`: un click **rilevato** su un link
        associato a un destinatario che, al momento della rilevazione, aveva un
        consenso valido. Non è la prova di un gesto umano: scanner di sicurezza
        e anteprime dei programmi di posta possono generarlo senza che nessuno
        abbia cliccato. Non contiene mai l'URL completo della destinazione, solo
        il suo host.


        La revoca del consenso blocca le nuove rilevazioni, ma non rende
        retroattivamente illecito l'evento già raccolto: gli eventi anteriori
        alla revoca che sono già in coda possono arrivare dopo, entro i 30
        giorni di conservazione della coda. La cancellazione del contatto
        elimina gli eventi e le consegne in coda. Chi riceve il webhook resta
        responsabile delle proprie copie e della gestione dei diritti su di
        esse.
      properties:
        click_id:
          type: string
          format: uuid
          description: Identifica il click. Un click, un evento.
        campaign_id:
          type: string
          format: uuid
        link_id:
          type: string
          format: uuid
          description: Il link tracciato, per campagna e destinazione.
        contact_id:
          type: string
          format: uuid
        recipient:
          type: string
          format: email
        destination_host:
          type: string
          description: Solo il nome host della destinazione, mai il percorso né i parametri.
    WebhookContactData:
      type: object
      required: [ contact_id, email ]
      properties:
        contact_id:
          type: [ string, "null" ]
          format: uuid
          description: >-
            **Può essere `null`**: lo è sempre nei `contact.unsubscribed`
            generati da `POST /contacts/revoke`, che l'indirizzo sia un contatto
            del brand o no (i due casi sono volutamente indistinguibili).
            Identifica l'indirizzo con `email`, non con `contact_id`.
        email:
          type: string
          format: email
    WebhookTestData:
      type: object
      required: [ message ]
      properties:
        message:
          type: string
    RevocationFeedPage:
      type: object
      required: [ items, next_cursor, has_more ]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/RevocationFeedItem"
        next_cursor:
          type: [ string, "null" ]
          description: >-
            La posizione da rimandare in `since`. Stringa opaca. Nullo soltanto
            se non esiste ancora alcuna posizione — coda vuota e nessun `since`
            in ingresso. A coda finita NON è nullo: è la tua posizione e resta
            valida.
        has_more:
          type: boolean
          description: >-
            `true` se esiste almeno un'altra pagina. Scorri finché non diventa
            `false`.
    RevocationFeedItem:
      type: object
      required: [ email, revoked_at, origin ]
      properties:
        email:
          type: string
          format: email
        revoked_at:
          type: string
          format: date-time
          description: >-
            L'istante della revoca, in UTC. È quello della PRIMA revoca di
            questo indirizzo per questo brand: una revoca ripetuta non lo
            sposta.
        origin:
          type: string
          enum: [ unsubscribe_link, console, api_key, undeclared, unknown ]
          description: >-
            Chi ha agito. Mai quale credenziale.
    CreateContactRequest:
      type: object
      required: [ email ]
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          description: Nome del contatto, facoltativo.
        brand_id:
          type: string
          format: uuid
          description: >-
            Con una API key: facoltativo, e vale come **asserzione** — se
            presente deve coincidere con il brand della chiave, altrimenti `403
            brand_mismatch`. Non sceglie mai il brand. Con la sessione cookie:
            **obbligatorio**.
        language:
          type: string
          enum: [ it, en, es, de, fr ]
          description: >-
            Lingua dell'iscrizione, a scelta di chi chiama l'API: è la lingua
            della pagina di conferma e, se il marchio ha acceso il tracciamento
            dei click, della richiesta di consenso che la persona vede e di cui
            conserviamo il testo. Facoltativa; senza, vale la lingua di
            comunicazione del marchio. Un valore che non è una delle cinque
            produce `400 invalid_language`.
    ContactAddressRequest:
      type: object
      required: [ email ]
      properties:
        email:
          type: string
          format: email
          description: >-
            Il contatto si indirizza per `(brand, email)`. Non esiste un
            identificativo da usare qui: nessuna risposta di questa superficie
            ne restituisce uno.
        brand_id:
          type: string
          format: uuid
          description: Asserzione facoltativa, come in `CreateContactRequest`.
    SendAttachment:
      type: object
      required: [ filename, content_type, content ]
      properties:
        filename:
          type: string
          minLength: 1
          maxLength: 100
          description: >-
            Nome del file come lo vedrà il destinatario, con l'estensione
            coerente con `content_type` (per esempio `fattura.pdf`). Percorsi e
            caratteri di controllo sono rimossi; oltre 100 caratteri il nome è
            accorciato conservando l'estensione.
        content_type:
          type: string
          enum:
            - application/pdf
            - image/png
            - image/jpeg
            - image/gif
            - text/plain
            - text/csv
            - text/calendar
          description: >-
            Elenco chiuso. Il contenuto deve corrispondere al tipo dichiarato
            (controllo sui primi byte per PDF e immagini, UTF-8 valido per i
            tipi di testo); altrimenti `400 attachment_type_not_allowed`.
            Eseguibili, archivi e documenti Office non sono ammessi.
        content:
          type: string
          format: byte
          description: >-
            Contenuto del file in base64 standard (RFC 4648 §4, con padding),
            senza prefisso `data:`.
      example:
        filename: fattura-2026-0412.pdf
        content_type: application/pdf
        content: JVBERi0xLjcK...
    SendRequestDirect:
      type: object
      required: [ from, to, subject ]
      properties:
        from:
          type: string
          format: email
          description: Indirizzo mittente. Il dominio deve essere verificato per il tenant.
        from_name:
          type: string
          minLength: 1
          maxLength: 78
          description: >-
            Nome che il destinatario legge al posto dell'indirizzo, per esempio
            `Acme Assistenza`. Facoltativo: se manca si usa il nome impostato
            sul brand, e se nemmeno il brand ne ha uno il messaggio parte con il
            solo indirizzo, come prima. Da 1 a 78 caratteri; non può contenere
            `@`, `<`, `>`, virgolette, a-capo né indirizzi web (il nome di un
            mittente non deve poter sembrare un altro indirizzo). I caratteri
            accentati e non latini sono ammessi. Un valore non valido produce
            `400 invalid_from_name`. Non cambia l'indirizzo `from`, che resta
            soggetto agli stessi controlli sul dominio.
        reply_to:
          type: string
          format: email
          description: >-
            Indirizzo a cui vanno le risposte dei destinatari a questo
            messaggio. Facoltativo; **può essere su qualunque dominio**, non
            deve coincidere con quello del brand, ma non può essere un indirizzo
            del dominio di mailronix. Un solo indirizzo, senza nome
            visualizzato. Prevale sul Reply-To impostato sul brand. **Richiede
            il consenso del brand**: l'impostazione «Consenti reply_to nella
            richiesta» si attiva dalla console con la sessione ed è spenta di
            default; finché è spenta la richiesta riceve `403
            reply_to_not_allowed`. Non si attiva con una API key, di proposito:
            una chiave sottratta non deve poter reindirizzare le risposte dei
            tuoi destinatari. Un indirizzo non valido produce `400
            invalid_reply_to`.
        language:
          type: string
          enum: [ it, en, es, de, fr ]
          description: >-
            Lingua di questo messaggio, a scelta di chi lo invia. Facoltativa;
            senza, vale la lingua di comunicazione del marchio al momento
            dell'invio. Determina la lingua del **piè di pagina di
            disiscrizione** dei messaggi marketing e, se il marchio ha acceso il
            tracciamento dei click, del link «Preferenze» e delle pagine a cui
            porta, compresa la richiesta di consenso: la lingua scelta qui resta
            legata a questo messaggio e **non cambia** se in seguito cambia la
            lingua del marchio. Accetta anche la forma con regione (`it-IT`),
            ridotta alla lingua. Un valore che non è una delle cinque produce
            `400 invalid_language`: non c'è un ripiego silenzioso.
        attachments:
          type: array
          maxItems: 5
          items:
            $ref: "#/components/schemas/SendAttachment"
          description: >-
            Allegati di questo messaggio. Facoltativo; **fino a 5 per messaggio,
            7 MB in totale** (dimensione dopo la decodifica base64), con
            l'**email completa sotto 10 MB**: in email gli allegati sono
            codificati in base64 e pesano circa un terzo in più, quindi 7 MB di
            file sono circa 9,6 MB di messaggio e il resto è lo spazio per il
            corpo. Solo su messaggi **transazionali**: con `marketing: true` la
            richiesta riceve `400 attachments_not_allowed_for_marketing`.
            Ammessi con `template_id` e su tutti i piani. Ogni allegato parte
            sempre come allegato, mai incorporato nel corpo. Conservazione: i
            byte restano nel nostro database solo il tempo dell'invio, e sono
            cancellati alla consegna a SES o all'esito definitivo, in ogni caso
            dopo 24 ore al massimo (più pochi minuti di pulizia); il log del
            messaggio conserva solo nome, tipo, dimensione e impronta SHA-256,
            mai il contenuto. Non c'è scansione antivirus: ciò che alleghi è
            responsabilità tua. Un ritentativo dello stesso invio con la stessa
            `Idempotency-Key` deve rimandare gli **stessi byte**. Il tetto di 10
            MB sull'email è scelto perché molti server di destinazione rifiutano
            messaggi più grandi (Gmail e Outlook arrivano a 20-25 MB); un
            rifiuto del server del destinatario compare comunque come bounce.
        to:
          type: string
          format: email
        subject:
          type: string
        html_body:
          type: string
          description: >-
            Corpo HTML. Obbligatorio se `text_body` è assente. Se lo mandi da
            solo, la parte testo del messaggio viene derivata da qui — link resi
            come `etichetta (url)`; vedi "Le due parti del messaggio" nella
            descrizione dell'operazione.
        text_body:
          type: string
          description: >-
            Corpo testo semplice. Obbligatorio se `html_body` è assente. Se lo
            mandi da solo, la parte HTML viene derivata da qui, in un layout
            neutro senza marchio mailronix; vedi "Le due parti del messaggio"
            nella descrizione dell'operazione.
        brand_id:
          type: string
          format: uuid
          description: >-
            Con una API key: facoltativo, e vale come **asserzione** — se
            presente deve coincidere con il brand a cui la chiave è legata,
            altrimenti `403 brand_mismatch`. Non sceglie mai il brand. Con la
            sessione cookie: **obbligatorio**. Vedi "Il brand del messaggio"
            nella descrizione dell'operazione.
        marketing:
          type: boolean
          default: false
          description: >-
            Dichiara che questo messaggio è **marketing**. Vedi "Il flag
            marketing" nella descrizione dell'operazione: aggiunge controlli,
            non ne toglie nessuno, e non esiste alcun valore che possa
            qualificare un messaggio come transazionale.
      description: >-
        Almeno uno tra `html_body`/`text_body` è obbligatorio insieme a
        `subject`. Non deve comparire `template_id`. Fornirne uno solo è
        legittimo: la parte mancante viene derivata e il destinatario riceve
        comunque entrambe, mai una vuota.
    SendRequestTemplate:
      type: object
      required: [ from, to, template_id ]
      properties:
        from:
          type: string
          format: email
        from_name:
          type: string
          minLength: 1
          maxLength: 78
          description: >-
            Nome che il destinatario legge al posto dell'indirizzo, per esempio
            `Acme Assistenza`. Facoltativo: se manca si usa il nome impostato
            sul brand, e se nemmeno il brand ne ha uno il messaggio parte con il
            solo indirizzo, come prima. Da 1 a 78 caratteri; non può contenere
            `@`, `<`, `>`, virgolette, a-capo né indirizzi web (il nome di un
            mittente non deve poter sembrare un altro indirizzo). I caratteri
            accentati e non latini sono ammessi. Un valore non valido produce
            `400 invalid_from_name`. Non cambia l'indirizzo `from`, che resta
            soggetto agli stessi controlli sul dominio.
        reply_to:
          type: string
          format: email
          description: >-
            Indirizzo a cui vanno le risposte dei destinatari a questo
            messaggio. Facoltativo; **può essere su qualunque dominio**, non
            deve coincidere con quello del brand, ma non può essere un indirizzo
            del dominio di mailronix. Un solo indirizzo, senza nome
            visualizzato. Prevale sul Reply-To impostato sul brand. **Richiede
            il consenso del brand**: l'impostazione «Consenti reply_to nella
            richiesta» si attiva dalla console con la sessione ed è spenta di
            default; finché è spenta la richiesta riceve `403
            reply_to_not_allowed`. Non si attiva con una API key, di proposito:
            una chiave sottratta non deve poter reindirizzare le risposte dei
            tuoi destinatari. Un indirizzo non valido produce `400
            invalid_reply_to`.
        language:
          type: string
          enum: [ it, en, es, de, fr ]
          description: >-
            Lingua di questo messaggio, a scelta di chi lo invia. Facoltativa;
            senza, vale la lingua di comunicazione del marchio al momento
            dell'invio. Determina la lingua del **piè di pagina di
            disiscrizione** dei messaggi marketing e, se il marchio ha acceso il
            tracciamento dei click, del link «Preferenze» e delle pagine a cui
            porta, compresa la richiesta di consenso: la lingua scelta qui resta
            legata a questo messaggio e **non cambia** se in seguito cambia la
            lingua del marchio. Accetta anche la forma con regione (`it-IT`),
            ridotta alla lingua. Un valore che non è una delle cinque produce
            `400 invalid_language`: non c'è un ripiego silenzioso.
        attachments:
          type: array
          maxItems: 5
          items:
            $ref: "#/components/schemas/SendAttachment"
          description: >-
            Allegati di questo messaggio. Facoltativo; **fino a 5 per messaggio,
            7 MB in totale** (dimensione dopo la decodifica base64), con
            l'**email completa sotto 10 MB**: in email gli allegati sono
            codificati in base64 e pesano circa un terzo in più, quindi 7 MB di
            file sono circa 9,6 MB di messaggio e il resto è lo spazio per il
            corpo. Solo su messaggi **transazionali**: con `marketing: true` la
            richiesta riceve `400 attachments_not_allowed_for_marketing`.
            Ammessi con `template_id` e su tutti i piani. Ogni allegato parte
            sempre come allegato, mai incorporato nel corpo. Conservazione: i
            byte restano nel nostro database solo il tempo dell'invio, e sono
            cancellati alla consegna a SES o all'esito definitivo, in ogni caso
            dopo 24 ore al massimo (più pochi minuti di pulizia); il log del
            messaggio conserva solo nome, tipo, dimensione e impronta SHA-256,
            mai il contenuto. Non c'è scansione antivirus: ciò che alleghi è
            responsabilità tua. Un ritentativo dello stesso invio con la stessa
            `Idempotency-Key` deve rimandare gli **stessi byte**. Il tetto di 10
            MB sull'email è scelto perché molti server di destinazione rifiutano
            messaggi più grandi (Gmail e Outlook arrivano a 20-25 MB); un
            rifiuto del server del destinatario compare comunque come bounce.
        to:
          type: string
          format: email
        template_id:
          type: string
          format: uuid
          description: >-
            Id di un template creato in console (`POST /email-templates`, fuori
            perimetro di questo documento), di proprietà dello stesso tenant a
            cui appartiene la chiave/sessione usata. Un template produce solo la
            parte HTML: la parte testo del messaggio viene sempre derivata da
            quell'HTML (vedi "Le due parti del messaggio" nella descrizione
            dell'operazione).
        variables:
          type: object
          additionalProperties:
            type: string
          description: >-
            Mappa `nome variabile → valore`, sostituita nei segnaposto
            `{{nome_variabile}}` del template. Una variabile referenziata dal
            template ma assente qui produce `400 missing_template_variable`.
        brand_id:
          type: string
          format: uuid
          description: >-
            Come in `SendRequestDirect`: asserzione facoltativa con una API key,
            obbligatorio con la sessione cookie.
        marketing:
          type: boolean
          default: false
          description: >-
            Dichiara che questo messaggio è **marketing**. Vedi "Il flag
            marketing" nella descrizione dell'operazione: aggiunge controlli,
            non ne toglie nessuno, e non esiste alcun valore che possa
            qualificare un messaggio come transazionale.
      description: >-
        Alternativa a `SendRequestDirect`: non devono comparire `subject`/
        `html_body`/`text_body`.
    SendRequest:
      oneOf:
        - $ref: "#/components/schemas/SendRequestDirect"
        - $ref: "#/components/schemas/SendRequestTemplate"
    SendResponse:
      type: object
      required: [ status, email_log_id ]
      properties:
        status:
          type: string
          enum: [ queued ]
        email_log_id:
          type: string
          format: uuid
    ErrorResponse:
      type: object
      required: [ error ]
      properties:
        error:
          type: object
          required: [ code, message ]
          properties:
            code:
              type: string
              description: >-
                Codice stabile da usare per il branching applicativo — `message`
                è testo descrittivo (in italiano), non pensato per essere
                parsato.
            message:
              type: string
