openapi: 3.1.0
info:
  title: email-check-api
  version: 0.1.0
  summary: Détection d’e-mails jetables et frauduleux.
  description: |
    Évalue le risque d’une adresse e-mail (domaine jetable, MX, motif suspect)
    et renvoie un score de 0 à 100 avec une décision `block`.

    **À appeler uniquement côté serveur.** Un appel depuis le navigateur expose
    la clé API et se contourne trivialement (l’utilisateur peut sauter l’appel
    ou falsifier la réponse).
servers:
  - url: https://api.useksar.com
security:
  - ApiKeyAuth: []
paths:
  /v1/check-email:
    post:
      operationId: checkEmail
      summary: Vérifier une adresse e-mail
      description: |
        Étapes : validation syntaxique, normalisation, whitelist, listes de
        domaines jetables (sous-domaines compris), vérification MX (timeout
        300 ms, résultat en cache), analyse du motif, calcul du score.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckEmailRequest"
            examples:
              legit:
                value:
                  email: Jean.Dupont+promo@gmail.com
              disposable:
                value:
                  email: someone@yopmail.com
      responses:
        "200":
          description: Adresse analysée.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckEmailResponse"
              examples:
                legit:
                  summary: Alias Gmail légitime
                  value:
                    email: Jean.Dupont+promo@gmail.com
                    normalized_email: jeandupont@gmail.com
                    domain: gmail.com
                    risk_score: 0
                    block: false
                    reasons: []
                    checks:
                      disposable: false
                      whitelisted_domain: true
                      mx: valid
                      website: present
                      suspicious_pattern: false
                disposable:
                  summary: Domaine jetable
                  value:
                    email: someone@yopmail.com
                    normalized_email: someone@yopmail.com
                    domain: yopmail.com
                    risk_score: 90
                    block: true
                    reasons:
                      - disposable_domain
                    checks:
                      disposable: true
                      whitelisted_domain: false
                      mx: valid
                      website: present
                      suspicious_pattern: false
                dnsTimeout:
                  summary: DNS trop lent (MX inconnu)
                  value:
                    email: jean@slow-dns.example
                    normalized_email: jean@slow-dns.example
                    domain: slow-dns.example
                    risk_score: 10
                    block: false
                    reasons:
                      - mx_timeout
                    checks:
                      disposable: false
                      whitelisted_domain: false
                      mx: unknown
                      website: unknown
                      suspicious_pattern: false
        "400":
          description: >
            `invalid_email` : adresse syntaxiquement invalide.

            `invalid_request` : body absent, JSON malformé ou `email` qui n’est
            pas une chaîne.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalidEmail:
                  value:
                    error: invalid_email
                invalidRequest:
                  value:
                    error: invalid_request
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: invalid_api_key
        "403":
          description: La clé n’a pas le scope `check` (`insufficient_scope`), ou le
            compte est suspendu (`account_suspended`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: insufficient_scope
        "413":
          description: Body supérieur à 4 Ko.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: payload_too_large
        "415":
          description: Content-Type autre que application/json.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: unsupported_media_type
        "429":
          description: >
            `rate_limit_exceeded` : limite de débit de la clé (fenêtre
            glissante, `Retry-After`).

            `quota_exceeded` : quota mensuel de vérifications de l’offre atteint
            (comptes en

            libre-service), jusqu’au 1er du mois suivant (`resets_at`) ou à un
            changement d’offre.

            **Dans les deux cas, laissez passer l’inscription** : votre
            formulaire ne doit jamais

            tomber à cause de l’API.
          headers:
            Retry-After:
              description: Secondes à attendre avant de réessayer (rate_limit_exceeded).
              schema:
                type: integer
                minimum: 1
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
            X-Quota-Limit:
              $ref: "#/components/headers/X-Quota-Limit"
            X-Quota-Remaining:
              $ref: "#/components/headers/X-Quota-Remaining"
          content:
            application/json:
              schema:
                type: object
                required:
                  - error
                properties:
                  error:
                    type: string
                    enum:
                      - rate_limit_exceeded
                      - quota_exceeded
                  message:
                    type: string
                  quota:
                    type: integer
                  used:
                    type: integer
                  resets_at:
                    type: string
                    format: date-time
                  upgrade_url:
                    type: string
              examples:
                debit:
                  value:
                    error: rate_limit_exceeded
                quota:
                  value:
                    error: quota_exceeded
                    message: "Quota mensuel de vérifications atteint : laissez passer les
                      inscriptions et passez à une offre supérieure."
                    quota: 200
                    used: 200
                    resets_at: 2026-11-01T00:00:00.000Z
                    upgrade_url: https://useksar.com/fr/tarifs/
        "500":
          description: Erreur interne.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: internal_error
        "503":
          description: >
            Clé jamais vue et base de données injoignable (impossible de
            l’authentifier).

            Côté client : accepter l’inscription (fail-open).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: service_unavailable
  /v1/settings:
    get:
      operationId: getSettings
      summary: Lire les paramètres du client (scope admin)
      description: Paramètres actuels, valeurs par défaut comprises.
      responses:
        "200":
          description: Paramètres du client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Settings"
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: La clé n’a pas le scope `admin`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      operationId: putSettings
      summary: Remplacer les paramètres du client (scope admin)
      description: >
        Remplace l’ensemble des paramètres : un champ absent reprend sa valeur
        par

        défaut. Les domaines des listes sont normalisés ; les adresses e-mail
        sont

        normalisées puis remplacées par leur empreinte `hmac:<64 hex>` (propre
        au

        client) : elles ne sont jamais stockées ni renvoyées en clair. La prise
        en

        compte est immédiate.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Settings"
            example:
              disposable_action: block
              offer_threshold: 40
              allowlist:
                domains:
                  - partenaire.fr
                emails: []
              denylist:
                domains:
                  - concurrent.example
                emails:
                  - fraudeur@example.com
      responses:
        "200":
          description: Paramètres enregistrés (forme normalisée).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Settings"
        "400":
          description: Paramètres invalides (`invalid_settings`, détail par champ).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: invalid_settings
                details:
                  - path: offer_threshold
                    message: Too big
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: La clé n’a pas le scope `admin`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de données injoignable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/signups/{signup_id}/events:
    post:
      operationId: postSignupEvent
      summary: Renvoyer un événement d’inscription (scope check)
      description: >
        Mode sas : quand `offer.decision` vaut `defer`, renvoyez les preuves
        (e-mail confirmé,

        premier achat…) ; la réponse indique si l’offre peut maintenant être
        libérée.

        `account_age_24h` se déduit de la date d’inscription. Les événements
        servent aussi au

        rapport des économies (`offer_redeemed` avec `value_eur`). Idempotent :
        un même type

        ne compte qu’une fois par inscription.
      parameters:
        - name: signup_id
          in: path
          required: true
          schema:
            type: string
            example: sgn_01J9ZK5W3Q8M2B7R4T6V0XY1CD
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - type
              additionalProperties: false
              properties:
                type:
                  type: string
                  enum:
                    - email_verified
                    - first_purchase
                    - offer_redeemed
                    - offer_released
                value_eur:
                  type: number
                  minimum: 0
            example:
              type: email_verified
      responses:
        "200":
          description: Événement enregistré.
          content:
            application/json:
              schema:
                type: object
                required:
                  - offer_release
                  - met_conditions
                properties:
                  offer_release:
                    type: boolean
                    description: >
                      true si l’offre peut être donnée (accordée d’emblée, ou en
                      attente et

                      toutes les conditions remplies) ; toujours false si
                      refusée.
                  met_conditions:
                    type: array
                    items:
                      type: string
              example:
                offer_release: true
                met_conditions:
                  - email_verified
        "400":
          description: Type inconnu ou valeur invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: La clé n’a pas le scope `check`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Inscription inconnue ou appartenant à un autre client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: not_found
        "503":
          description: Base de données injoignable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/reports/summary:
    get:
      operationId: getReportSummary
      summary: Rapport des économies (scope admin)
      description: >
        Résumé d’une période (jours UTC, 366 jours maximum ; défaut : mois en
        cours).

        `estimated_savings_eur` est une **estimation**, dont la méthode est
        renvoyée :

        offres refusées × valeur de l’offre, + offres en attente jamais
        accordées (aucun

        événement `offer_released` ni `offer_redeemed`) × valeur de l’offre.
        Valeur = moyenne

        des `value_eur` réels reçus (`offer_redeemed`) pour le profil, sinon
        `offer_value_eur`

        configuré. En mode test, les décisions comptées sont celles qui auraient
        été prises.

        `format=html` : rapport HTML en français, prêt à être envoyé par e-mail.
      parameters:
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: format
          in: query
          schema:
            type: string
            enum:
              - json
              - html
      responses:
        "200":
          description: Résumé de la période.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportSummary"
            text/html:
              schema:
                type: string
        "400":
          description: Dates invalides ou période trop longue (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: "La clé n’a pas le scope `admin`, ou l’offre n’inclut pas le
            rapport (`plan_upgrade_required` : Pro et plus)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de données injoignable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/batch:
    post:
      operationId: submitBatch
      summary: Analyser un fichier CSV en asynchrone (scope admin)
      description: >
        Même moteur que `npm run audit`. Corps `text/csv` (5 Mo maximum, virgule
        ou point-virgule

        détectés). Réponse immédiate `202` avec `job_id` ; suivez le statut sur

        `/v1/batch/{job_id}`. Le fichier source n’est jamais stocké ; les
        résultats (sans

        aucune adresse) sont supprimés après 7 jours.
      parameters:
        - name: email_column
          in: query
          schema:
            type: string
            default: email
        - name: coupon_column
          in: query
          schema:
            type: string
        - name: date_column
          in: query
          schema:
            type: string
        - name: offer_value
          in: query
          schema:
            type: string
            example: "10"
        - name: delimiter
          in: query
          schema:
            type: string
            enum:
              - ","
              - ;
      requestBody:
        required: true
        content:
          text/csv:
            schema:
              type: string
            example: |
              email,code_utilise
              jean@example.com,BIENVENUE
      responses:
        "202":
          description: Fichier accepté.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id:
                    type: string
                    example: bat_01J9ZK5W3Q8M2B7R4T6V0XY1CD
                  status:
                    type: string
                    enum:
                      - queued
                  status_url:
                    type: string
        "400":
          description: Fichier vide ou paramètres invalides.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: "La clé n’a pas le scope `admin`, ou l’offre n’inclut pas l’audit
            CSV (`plan_upgrade_required` : Business et plus)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: Fichier supérieur à 5 Mo.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "415":
          description: Content-Type autre que text/csv.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/batch/{job_id}:
    get:
      operationId: getBatch
      summary: Statut et résumé d’une analyse (scope admin)
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: >
            `status` : `queued`, `running`, `done` (avec `summary` et
            `results_url`) ou

            `failed` (avec `error`, ex. colonne introuvable).
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id:
                    type: string
                  status:
                    type: string
                    enum:
                      - queued
                      - running
                      - done
                      - failed
                  created_at:
                    type: string
                    format: date-time
                  finished_at:
                    type: string
                    format: date-time
                  expires_at:
                    type: string
                    format: date-time
                  summary:
                    type: object
                  results_url:
                    type: string
                  error:
                    type: string
        "404":
          description: Analyse inconnue, expirée ou appartenant à un autre client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/batch/{job_id}/results.csv:
    get:
      operationId: getBatchResults
      summary: Résultats d’une analyse (CSV sans adresse e-mail)
      description: "Colonnes : line (numéro de ligne d’origine), status, reasons,
        duplicate_group."
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: CSV de résultats.
          content:
            text/csv:
              schema:
                type: string
        "404":
          description: Analyse inconnue, non terminée ou expirée.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/subjects:
    delete:
      operationId: eraseSubject
      summary: Droit à l’effacement (scope admin)
      description: >
        Supprime toutes les données liées à une adresse pour ce client
        (inscriptions et

        empreintes), variantes équivalentes comprises
        (`Jean.Dupont+x@googlemail.com` =

        `jeandupont@gmail.com`). L’adresse n’est ni stockée ni journalisée.
        Répond 204 même

        si rien ne correspondait.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  maxLength: 320
      responses:
        "204":
          description: Données effacées.
        "400":
          description: Adresse invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: invalid_email
        "401":
          description: Clé API absente ou invalide.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: La clé n’a pas le scope `admin`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Base de données injoignable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /health:
    get:
      operationId: health
      summary: Sonde de disponibilité (sans authentification)
      security: []
      responses:
        "200":
          description: Service opérationnel.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    const: ok
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: >
        Clé liée à un client (tenant), créée par `npm run tenant:create`. Scopes
        :

        `check` (vérifications et événements) et `admin` (paramètres, rapports).
  headers:
    X-Quota-Limit:
      description: Vérifications incluses ce mois-ci dans l’offre (comptes en
        libre-service).
      schema:
        type: integer
    X-Quota-Remaining:
      description: Vérifications restantes ce mois-ci après cet appel.
      schema:
        type: integer
    X-RateLimit-Limit:
      description: Requêtes autorisées par fenêtre pour cette clé.
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requêtes restantes (estimation, fenêtre glissante).
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Secondes avant la fin de la fenêtre courante.
      schema:
        type: integer
  schemas:
    CheckEmailRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          maxLength: 320
          example: jean.dupont@example.com
        event:
          type: string
          enum:
            - signup
            - check
          default: signup
          description: >
            `signup` (défaut) enregistre l’inscription (doublons, séries,
            rapports) et

            renvoie un `signup_id` ; `check` analyse seulement (validation en
            direct d’un

            formulaire, par exemple).
        external_id:
          type: string
          minLength: 1
          maxLength: 256
          description: Identifiant côté client (ex. `user_123`), stocké tel quel.
        offer:
          type: string
          pattern: ^[a-z0-9_-]{1,64}$
          description: Profil d’offre (`offer_profiles` des paramètres), ex. `welcome_10`.
    Offer:
      type: object
      required:
        - eligible
        - decision
        - offer_score
        - reasons
      description: >
        Faut-il accorder l’offre promo (réduction, essai, crédits) ?
        `offer_score` = risque d’abus de promotion

        (0-100) : doublon exact 85, jetable accepté par le client 50, série 45,
        sans site

        web 30, motif suspect 25-45, DNS lent 5. Décision : inscription bloquée
        → `deny` ;

        `offer_policy` `grant_all` / `deny_all` ; allowlist → `grant` ;
        politiques

        `disposable_action` et `duplicate_policy` ; sinon score <
        `deferred_band[0]` →

        `grant` (accordée), < `offer_threshold` → `defer` (en attente), au-delà
        → `deny`

        (refusée ; la plus sévère gagne). Un refus signale un risque d’abus, pas
        une fraude avérée.
      properties:
        eligible:
          type: boolean
          description: "`decision` vaut `grant`."
        decision:
          type: string
          enum:
            - grant
            - defer
            - deny
        offer_score:
          type: integer
          minimum: 0
          maximum: 100
        reasons:
          type: array
          items:
            $ref: "#/components/schemas/ReasonCode"
        profile:
          type: string
        release_conditions:
          type: array
          description: "Offre en attente (mode sas) : preuves attendues avant de
            l’accorder."
          items:
            type: string
            enum:
              - email_verified
              - first_purchase
              - account_age_24h
        signup_id:
          type: string
          description: "Offre en attente : inscription à laquelle renvoyer les événements."
    CheckEmailResponse:
      type: object
      required:
        - email
        - normalized_email
        - domain
        - risk_score
        - block
        - reasons
        - checks
      properties:
        email:
          type: string
          description: Adresse reçue (espaces de début et de fin retirés).
        normalized_email:
          type: string
          description: >
            Boîte réelle : minuscules, googlemail.com -> gmail.com, points
            retirés

            chez Gmail uniquement, alias "+" retirés chez les fournisseurs qui
            les

            supportent. À utiliser pour détecter les comptes multiples.
        domain:
          type: string
          description: Domaine en minuscules, en punycode pour les noms internationalisés.
        risk_score:
          type: integer
          minimum: 0
          maximum: 100
        block:
          type: boolean
          description: true si risk_score >= seuil (70 par défaut, configurable).
        reasons:
          type: array
          items:
            $ref: "#/components/schemas/ReasonCode"
        signup_id:
          type: string
          description: Identifiant opaque de l’inscription enregistrée (`event` = `signup`).
          example: sgn_01J9ZK5W3Q8M2B7R4T6V0XY1CD
        duplicate:
          type: object
          required:
            - status
            - count
          description: >
            Doublon après normalisation (`exact`) ou série d’adresses de même
            racine créées

            rapidement (`series`, ex. jean.dupont1, jean.dupont2…). Ne change
            jamais `block`

            ni `risk_score` : c’est une vraie adresse. `count` inclut
            l’inscription en cours

            (0 si la base était injoignable).
          properties:
            status:
              type: string
              enum:
                - none
                - exact
                - series
            count:
              type: integer
              minimum: 0
            first_seen_at:
              type: string
              format: date-time
        offer:
          $ref: "#/components/schemas/Offer"
        mode:
          type: string
          enum:
            - enforce
            - shadow
          description: >
            `shadow` (mode test) : `block` vaut toujours `false` et
            `offer.decision` toujours

            `grant` ; les décisions réelles sont dans `shadow` et enregistrées
            pour le rapport.
        shadow:
          type: object
          required:
            - would_block
            - would_offer_decision
          description: "Mode test uniquement : ce qu’aurait décidé le mode `enforce`."
          properties:
            would_block:
              type: boolean
            would_offer_decision:
              type: string
              enum:
                - grant
                - defer
                - deny
        degraded:
          type: boolean
          description: >
            true si une dépendance interne (base de données) était indisponible
            : la

            réponse repose sur les signaux disponibles et les paramètres par
            défaut.
        not_included:
          type: array
          items:
            type: string
            enum:
              - duplicates
              - offer_decision
              - gate
              - shadow_mode
          description: >
            Fonctions non incluses dans l’offre du compte (Free, Starter ; Pro
            et plus les

            incluent). La réponse garde la même forme : `duplicate.status` vaut
            `none`,

            `offer.decision` vaut `deny` si l’inscription est bloquée, sinon
            `grant`, et `mode`

            vaut toujours `enforce`. Absent pour les offres qui incluent tout.
        checks:
          type: object
          required:
            - disposable
            - whitelisted_domain
            - mx
            - website
            - suspicious_pattern
          properties:
            disposable:
              type: boolean
              description: Domaine, sous-domaine ou MX jetable.
            whitelisted_domain:
              type: boolean
              description: Grand fournisseur de confiance (exclut seulement le verdict
                jetable).
            mx:
              type: string
              enum:
                - valid
                - missing
                - unknown
            website:
              type: string
              enum:
                - present
                - missing
                - unknown
              description: Le domaine (ou www) a une adresse IP. `unknown` en cas de doute
                (DNS lent ou en erreur).
            suspicious_pattern:
              type: boolean
              description: Motif suspect dans la partie locale. Ne bloque jamais seul.
            privacy_relay:
              type: boolean
              description: >
                Alias de confidentialité (vrai client, jamais jetable). Effet
                selon

                `privacy_relay_policy` : `allow`, `defer` (défaut), `deny_offer`
                ou `block`.
            domain_age_days:
              type:
                - integer
                - "null"
              description: >
                Âge du domaine en jours (RDAP). Recherché en tâche de fond au
                premier passage

                d’un domaine inconnu : `null` tant qu’inconnu, puis en cache 30
                jours.
            catch_all:
              type:
                - boolean
                - "null"
              description: >
                Le domaine accepte toutes les adresses (sondage SMTP en tâche de
                fond, cache

                7 jours). Désactivé par défaut (`FEATURE_CATCHALL_PROBE`) :
                `null`.
    ReasonCode:
      type: string
      enum:
        - disposable_domain
        - disposable_subdomain
        - disposable_mx
        - no_mx
        - mx_timeout
        - suspicious_pattern
        - no_website
        - tenant_allowlist
        - tenant_denylist
        - duplicate_exact
        - duplicate_series
        - privacy_relay
        - young_domain
        - new_catch_all_domain
      description: >
        | Code | Points | Signification |

        |---|---|---|

        | disposable_domain | 90 | Domaine jetable connu |

        | disposable_subdomain | 85 | Sous-domaine d’un domaine jetable |

        | disposable_mx | 80 | Domaine inconnu dont le MX appartient à un
        service jetable |

        | no_mx | 75 | Domaine inexistant ou sans MX : ne peut pas recevoir
        d’e-mail |

        | mx_timeout | 10 | DNS trop lent : MX non vérifié |

        | no_website | 50 | Domaine sans site web (ni IP sur l’apex ni sur www)
        : typique des domaines jetables tournants |

        | suspicious_pattern | 20-40 | Partie locale aléatoire, de test, suite
        de clavier… |

        | tenant_allowlist | 0 | Domaine ou adresse dans la liste autorisée du
        client : `block: false` |

        | tenant_denylist | 0 | Domaine ou adresse dans la liste interdite du
        client : `block: true` (prioritaire) |

        | duplicate_exact | 0 | Adresse déjà inscrite chez ce client (après
        normalisation) |

        | duplicate_series | 0 | Série d’adresses de même racine dans la fenêtre
        (`duplicate_policy`) |

        | young_domain | 10 | Domaine créé il y a moins de 30 jours (RDAP, en
        tâche de fond) |

        | new_catch_all_domain | 30 | Domaine récent qui accepte toutes les
        adresses (sondage SMTP, désactivé par défaut) |

        | privacy_relay | 0 | Alias de confidentialité (Hide My Email, Firefox
        Relay, Duck…) : effet selon `privacy_relay_policy` |


        Seules les preuves fortes (jetable, sans MX) bloquent seules ;
        `no_website`

        bloque uniquement combiné à `suspicious_pattern`.
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          enum:
            - invalid_email
            - invalid_request
            - invalid_api_key
            - payload_too_large
            - unsupported_media_type
            - rate_limit_exceeded
            - not_found
            - internal_error
            - insufficient_scope
            - service_unavailable
            - invalid_settings
        details:
          type: array
          description: Détail des erreurs de validation (`invalid_settings`).
          items:
            type: object
            properties:
              path:
                type: string
              message:
                type: string
    ReportSummary:
      type: object
      properties:
        period:
          type: object
          properties:
            from:
              type: string
              format: date
            to:
              type: string
              format: date
        checks:
          type: integer
          description: Appels à /v1/check-email.
        signups:
          type: integer
        blocked:
          type: integer
        offers:
          type: object
          properties:
            granted:
              type: integer
            deferred:
              type: integer
            denied:
              type: integer
        abuse_signals:
          type: object
          properties:
            disposable:
              type: integer
            duplicate_exact:
              type: integer
            duplicate_series:
              type: integer
            privacy_relay:
              type: integer
        estimated_savings_eur:
          type: number
        currency:
          type: string
        method:
          type: string
        mode:
          type: string
          enum:
            - enforce
            - shadow
      example:
        period:
          from: 2026-10-01
          to: 2026-10-31
        checks: 1400
        signups: 1240
        blocked: 41
        offers:
          granted: 1105
          deferred: 62
          denied: 73
        abuse_signals:
          disposable: 41
          duplicate_exact: 28
          duplicate_series: 9
          privacy_relay: 17
        estimated_savings_eur: 730
        currency: EUR
        method: offres refusées × valeur de l’offre, + offres en attente jamais
          accordées × valeur de l’offre
        mode: enforce
    ListRule:
      type: object
      additionalProperties: false
      properties:
        domains:
          type: array
          items:
            type: string
          description: Domaines (sous-domaines compris).
        emails:
          type: array
          items:
            type: string
          description: Adresses en clair à l’envoi ; stockées et renvoyées sous forme
            `hmac:<64 hex>`.
    Settings:
      type: object
      additionalProperties: false
      description: Paramètres et règles du client. Tous les champs sont facultatifs
        (valeur par défaut).
      properties:
        mode:
          type: string
          enum:
            - enforce
            - shadow
          default: enforce
        disposable_action:
          type: string
          enum:
            - block
            - deny_offer
            - allow
          default: block
        offer_policy:
          type: string
          enum:
            - scored
            - grant_all
            - deny_all
          default: scored
        block_threshold:
          type: integer
          minimum: 0
          maximum: 100
          default: 70
        offer_threshold:
          type: integer
          minimum: 0
          maximum: 100
          default: 40
        deferred_band:
          type: array
          items:
            type: integer
            minimum: 0
            maximum: 100
          minItems: 2
          maxItems: 2
          default:
            - 25
            - 40
        privacy_relay_policy:
          type: string
          enum:
            - allow
            - defer
            - deny_offer
            - block
          default: defer
        duplicate_policy:
          type: object
          properties:
            exact:
              type: string
              enum:
                - allow
                - defer
                - deny_offer
              default: deny_offer
            series:
              type: string
              enum:
                - allow
                - defer
                - deny_offer
              default: defer
            series_window_hours:
              type: integer
              minimum: 1
              default: 24
            series_min_count:
              type: integer
              minimum: 2
              default: 3
        allowlist:
          $ref: "#/components/schemas/ListRule"
        denylist:
          $ref: "#/components/schemas/ListRule"
        offer_profiles:
          type: object
          description: Surcharges par profil d’offre (`welcome_10`, `free_trial`…).
          additionalProperties:
            type: object
            properties:
              offer_policy:
                type: string
                enum:
                  - scored
                  - grant_all
                  - deny_all
              offer_threshold:
                type: integer
                minimum: 0
                maximum: 100
              deferred_band:
                type: array
                items:
                  type: integer
              privacy_relay_policy:
                type: string
                enum:
                  - allow
                  - defer
                  - deny_offer
                  - block
              offer_value_eur:
                type: number
                minimum: 0
        economics:
          type: object
          properties:
            offer_value_eur:
              type: number
              minimum: 0
              default: 10
            currency:
              type: string
              default: EUR
        retention_days:
          type: integer
          minimum: 1
          default: 395
        deferred_release_conditions:
          type: array
          items:
            type: string
            enum:
              - email_verified
              - first_purchase
              - account_age_24h
          default:
            - email_verified
