Trame · Documentation

Authentification et scopes#

L'API publique s'authentifie par clé API, envoyée dans l'en-tête X-Api-Key (un préfixe Authorization: Bearer <clé> est aussi accepté). Jamais de cookie, jamais de jeton de session utilisateur — l'API à clé est totalement indépendante de votre compte de connexion à l'application.

Chaque clé :

  • est générée une fois, montrée en clair une seule fois à sa création — seul son hash SHA-256 est conservé côté serveur ;
  • est vérifiée en temps constant (timingSafeEqual), pour ne fuiter aucune information par le temps de réponse ;
  • est scopée à un seul tenant et porte une liste de scopes explicites — jamais d'accès croisé entre tenants ;
  • peut être de type human (créée par une personne du tenant, s'exécute directement) ou agent (créée pour un assistant IA, peut exiger une approbation humaine avant toute écriture — voir MCP). Le type n'est pas qu'une étiquette : une clé agent ne peut jamais envoyer de message, quel que soit son mode d'approbation et quels que soient ses scopes.

Les 11 scopes réels#

ScopeEffet
read:conversationsLister / lire le détail des conversations, rechercher (GET /v1/search).
write:conversationsAssigner, commenter, poser/retirer des labels sur une conversation.
read:contactsLire les contacts.
write:contactsCréer / modifier / supprimer des contacts.
read:calendarLire calendriers et événements.
write:calendarCréer / modifier / supprimer des calendriers et événements.
read:tasksLire les tâches.
write:tasksCréer / modifier / supprimer des tâches.
read:analyticsLire le résumé analytics (volumes).
drafts:writeCréer, modifier, supprimer des brouillons (CRUD complet — jamais un envoi).
mail:sendEnvoyer un message au nom d'une boîte accessible à la clé. Voir section dédiée ci-dessous.

Une clé créée avant l'introduction d'un scope et qui ne porte aucun champ scopes retombe automatiquement sur ['read:conversations'] — jamais sur un scope d'écriture, jamais sur mail:send. Un administrateur peut ajouter ou retirer un scope sur une clé existante à tout moment depuis les réglages de l'espace (édition des scopes) ; l'effet est immédiat, sans avoir à régénérer la clé.

mail:send#

Le scope mail:send autorise une clé à envoyer un message (nouveau ou en réponse à un fil) au nom d'une boîte à laquelle elle a accès en écriture.

Ce qui garde réellement l'envoi#

Trois choses, et une seule d'entre elles est spécifique à votre intégration :

  1. Le scope, strictement opt-in. mail:send n'est jamais pré-coché à la création d'une clé, n'est jamais impliqué par write:conversations, et n'est jamais hérité par une clé sans champ scopes. Un administrateur de l'espace doit l'activer explicitement.
  2. Le refus des clés de type agent. Voir la section dédiée ci-dessous : ce refus ne dépend d'aucun réglage et ne peut pas être levé par une approbation.
  3. Un interrupteur d'envoi global — il doit être ouvert pour que le moindre message parte, fermé il fait répondre 403 avant tout appel sortant.

⚠️ Le point 3 n'est pas une barrière propre à l'API. Cet interrupteur est le même que celui de l'application : c'est lui qui autorise aussi les membres de l'espace à envoyer du courrier depuis Trame. Sur un espace en service, il est donc ouvert. Ne le comptez jamais comme une protection contre un envoi non voulu par une intégration — les deux portes qui vous concernent sont le scope et le type de la clé.

État de déploiement (juillet 2026) : les routes d'envoi de l'API publique ne sont pas encore actives en production. Le service qui sert l'API publique n'a pas été redéployé depuis leur ajout : elles répondent aujourd'hui 404, comme une route inconnue. C'est un état conjoncturel, pas une garantie — au premier déploiement de ce service elles s'ouvrent, gardées uniquement par les points 1 et 2.

Points clés#

  • Idempotency-Key obligatoire — chaque requête d'envoi doit porter un en-tête Idempotency-Key (8 à 200 caractères imprimables). Un rejeu avec la même clé + la même boîte + la même valeur et le même contenu ne produit jamais un second envoi : la réponse initiale est simplement renvoyée (en-tête X-EPB-Idempotency: hit ou hit-mem). Réutiliser la même valeur avec un contenu différent est refusé 409 IDEMPOTENCY_KEY_REUSED : c'est une erreur d'intégration, jamais un envoi silencieusement perdu — voir la section « Portée exacte de la garantie d'idempotence » pour la troisième issue possible.
  • Rate-limit dédié — 10 requêtes/min par clé, compteur indépendant des autres routes, voir Vue d'ensemble. Ce plafond est appliqué par instance de service : sous charge, le débit agrégé peut dépasser cette valeur. C'est un garde-fou, pas une garantie contractuelle.
  • Plafonds d'adresses — deux bornes indépendantes : une par champ (to, cc, bcc) et une par message (to + cc + bcc réunis). Chaque champ peut être sous son plafond et le message être refusé quand même. Une adresse dont la forme n'est pas exploitable (objet sans champ email, valeur qui n'est ni une chaîne ni un objet, entrée portant deux adresses) est refusée en 400 avant tout envoi, jamais devinée. Valeurs et détails : Endpoints REST, section « Plafonds des routes d'envoi ».
  • Statut DLP simplifié — la réponse ne renvoie jamais le détail de la politique de prévention de fuite de données du tenant, seulement un statut simplifié : ok, blocked ou warning_required (avec un jeton à renvoyer pour confirmer un envoi malgré l'avertissement, comme le ferait un humain dans l'application).

Portée exacte de la garantie d'idempotence#

La règle appliquée est celle-ci, et c'est elle qu'il faut retenir :

Le service ne rend jamais le reçu d'un message dont il ne peut pas prouver qu'il correspond à votre demande.

La preuve n'est pas la clé, c'est une empreinte de la charge utile enregistrée avec la réservation par les routes d'envoi de l'API. D'où trois issues, jamais deux :

SituationRéponseEn-tête X-EPB-IdempotencyMessage envoyé ?
Empreinte identique (vrai rejeu)200 + le reçu d'originehit ou hit-memnon — le premier est déjà parti
Empreinte comparable et différente409 IDEMPOTENCY_KEY_REUSEDkey-reusednon
Rien de comparable409 IDEMPOTENCY_KEY_UNVERIFIABLEkey-unverifiablenon

Le troisième cas mérite une explication, parce qu'il est propre à Trame. Le verrou d'envoi est partagé avec les autres voies d'envoi de l'espace : l'application elle-même réserve dans le même verrou, avec la même formule de clé (espace + boîte + votre valeur d'Idempotency-Key), mais sans empreinte. Face à une telle réservation — ou à une empreinte produite par une version antérieure de l'algorithme — il n'y a rien à comparer. Le service refuse alors plutôt que de vous rendre un reçu emprunté : votre message n'a jamais été envoyé, rejouez avec une nouvelle valeur d'Idempotency-Key, cela ne créera aucun doublon.

Contrepartie assumée, à connaître : après un changement de l'algorithme d'empreinte, un rejeu par ailleurs légitime peut recevoir ce même 409 tant que les réservations d'avant le changement n'ont pas expiré. Repartir avec une nouvelle valeur peut alors produire un doublon — visible, là où l'alternative était une perte silencieuse. Les deux 409 portent des codes distincts précisément pour que vous puissiez distinguer les deux situations.

En pratique : utilisez une valeur unique par message (un identifiant aléatoire — jamais un compteur, jamais un identifiant métier partagé avec un autre système). Le troisième cas devient alors inatteignable.

Clé de type agent : refus d'envoi#

Une clé kind: 'agent' est refusée 403 sur toute route d'envoi, quel que soit son mode d'approbation (required comme none) et même si elle porte déjà mail:send.

Ce sont deux barrières distinctes, qu'il ne faut pas confondre :

  • à l'attribution du droit — accorder mail:send à une clé de type agent est refusé 400, à la création de la clé comme à l'édition de ses scopes ;
  • au moment de l'appel — si une clé de ce type porte malgré tout le scope (droit accordé avant l'introduction de cette règle, clé importée ou migrée), l'envoi est refusé à l'exécution. La seule barrière à l'attribution ne suffirait pas : elle ne nettoie pas les clés déjà en base.

Aucune approbation humaine ne peut débloquer un envoi sur ce type de clé : l'envoi n'entre jamais dans la file d'approbation. Pour un droit d'envoi, créez une clé de type humain.

Responsabilité du porteur de la clé#

En activant le scope mail:send sur une clé, l'administrateur de l'espace reconnaît que :

  • le contenu, les destinataires et le moment de l'envoi sont entièrement déterminés par l'intégration qui détient la clé — Trame ne relit ni ne modère le contenu envoyé ;
  • Trame applique des garde-fous techniques (limite de fréquence, clé d'idempotence obligatoire, politique de prévention de fuite de données déjà configurée sur l'espace) mais n'est pas responsable d'un envoi erroné, dupliqué par erreur logique côté client, ou non conforme, déclenché par cette intégration ;
  • l'intégration reste responsable de la conformité de ses envois (consentement des destinataires, contenu, respect des lois applicables sur la communication électronique) ;
  • un envoi via l'API produit exactement le même message, dans la même boîte, que s'il avait été envoyé manuellement — il est irréversible une fois parti ;
  • ce droit peut être révoqué à tout moment en désactivant le scope sur la clé, sans affecter les autres droits de la clé (lecture, brouillons, contacts…).

Statut : ce texte est une proposition de wording (non encore validée juridiquement) — voir documents/specs/mail-send-scope-spec.md §5 pour la version source.

Écritures d'agent et approbation#

Une clé kind: 'agent' avec approval: 'required' ne s'exécute jamais directement pour une écriture (assign/comment/labels/archive/reopen/snooze, brouillons, contacts/agenda/tâches) : la requête est mise en file d'approbation humaine et répond 202 { "approvalRequired": true, "approval": {...} }. L'écriture n'a lieu qu'après approbation. L'en-tête Idempotency-Key devient obligatoire dans ce cas, y compris sur les routes où il est facultatif pour une clé humaine (sinon 400).

Deux exceptions :

  • mail:send est refusé (403) d'emblée pour toute clé de type agent, approval: 'required' ou non (voir ci-dessus), jamais mis en file ;
  • DELETE /v1/calendars/{id} n'est pas reconnu par la file d'approbation et répond 404 à une clé de ce type — c'est un manque du code, pas une garde volontaire : cette suppression n'est aujourd'hui pas réalisable par une clé agent à approbation requise.

POST /mcp ne passe pas par ce gate : le serveur MCP gère sa propre file d'approbation, cette route ne répond donc jamais 202.

Ressources d'espace : c'est le scope qui décide, pas le droit sur une boîte#

Règle — pour une ressource de TENANT (contacts, tâches, calendrier, test de webhook), l'autorité d'écriture est le scope de la clé, jamais le drapeau « lecture seule » d'une boîte.

Conséquence à connaître avant d'accorder un scope, et assumée : une clé qui n'a qu'un droit de lecture sur une boîte peut créer, modifier et supprimer contacts, tâches et événements d'agenda — dès lors qu'un administrateur lui a accordé write:contacts, write:tasks ou write:calendar. Aucun refus ne viendra du côté « boîte » : ces routes ne portent pas de paramètre mailbox et ne passent volontairement par aucun contrôle d'écriture par boîte.

Pourquoi : le drapeau « lecture seule » dérive d'une délégation par boîte. Un carnet d'adresses, une tâche ou un événement n'appartient à aucune boîte — ce drapeau ne dit littéralement rien d'eux, et le rattacher à une boîte arbitraire (laquelle ? une clé peut être lectrice sur l'une et rédactrice sur l'autre) inventerait une autorisation qui n'existe nulle part ailleurs dans le produit, pas même pour un humain dans l'application. La seule autorité qui soit à l'échelle de l'espace est donc le scope, accordé uniquement par un administrateur.

Ce qui reste garanti : une clé sans aucun scope d'écriture n'écrit rien, ici comme ailleurs (403, avant tout accès aux données). Le contrôle par boîte, lui, continue de s'appliquer pleinement là où il a un sens : conversations, commentaires, libellés, état d'un fil, brouillons et envoi.

Pratiquement : pour une intégration qui doit rester en lecture, ne lui accordez aucun scope write:* — restreindre son accès aux boîtes ne suffit pas.

Traçabilité des refus d'authentification#

Un 401 (clé absente, inconnue ou révoquée) et un 429 (plafond de fréquence dépassé) ne sont pas des refus muets — ce sont précisément les deux qui signalent un abus réel, une clé devinée ou une clé compromise :

  • ils sont toujours consignés dans le journal du service, avec la méthode, la route, le motif et un préfixe non réversible de l'empreinte de la clé présentée — jamais la clé elle-même ;
  • ils apparaissent en plus dans le journal d'audit de votre espace quand la clé présentée est reconnue comme une clé de cet espace (révoquée, incomplète, limitée en fréquence). Une clé totalement inconnue n'appartient à aucun espace : elle reste dans le journal du service uniquement — sinon n'importe qui pourrait faire grossir le journal d'un espace à volonté.

Ces entrées d'audit sont plafonnées par clé et par minute : une rafale de tentatives est visible sans noyer le journal. Cette traçabilité ne change rien à la réponse rendue : même statut, même message, même délai — il n'existe aucun moyen de distinguer « clé inconnue » de « clé révoquée » depuis le client.

Notes d'implémentation#

  • Vérification de clé en temps constant (timingSafeEqual), zéro clé stockée en clair.
  • La gestion des clés elles-mêmes (créer, révoquer, éditer les scopes) se fait depuis les réglages de votre espace Trame (authentifié par votre compte, pas par une clé API) — ce n'est pas une route de l'API publique /v1/....