# Lire un message depuis une suite de tests

> Une seule requête suffit : elle attend le message, et vous le rend entièrement analysé.
> https://facteur.eu/aide/lire-un-message

Trois routes suffisent à écrire un test.

```
POST /v1/messages/search          celui que vous cherchez, et il attend qu'il arrive
GET  /v1/inboxes/<id>/messages    les 50 derniers, du plus récent au plus ancien
GET  /v1/messages/<id>            un message, en entier
```

Utilisez la première. C'est celle qui attend le message pour vous, et celle qui vous
le rend entièrement analysé.

## Avant de commencer

Créez une clé d'API depuis
[**Réglages → Clés d'API**](https://app.facteur.eu/settings/api-keys) et envoyez-la dans
un en-tête :

```
Authorization: Bearer fk_votre_cle
```

Voir [Clés d'API](cles-api) pour ce qu'une clé atteint et comment la faire tourner.

## Attendre le message plutôt que le sonder

Ajoutez `wait`, en millisecondes, et la recherche ne répond pas tant que rien ne
correspond :

```bash
curl -X POST https://api.facteur.eu/v1/messages/search \
  -H "Authorization: Bearer fk_votre_cle" \
  -H "content-type: application/json" \
  -d '{"inbox": "j3k9x2mq", "sentTo": "commande-4821", "wait": 30000}'
```

C'est ce qui remplace la boucle `sleep` ou `retry` que vous écririez autrement.
**Lancez la recherche avant de déclencher l'action qui envoie l'email** : elle
attendra.

Six choses à savoir :

- **Elle répond dès l'arrivée du message**, pas à l'échéance.
- **Le message rendu est entièrement analysé** : sujet, code à usage unique, liens.
- **Rien trouvé à l'échéance donne un `200` avec une liste vide**, pas une erreur.
  C'est à votre test de décider si un message absent est un échec.
- **Cinq minutes au plafond.** Demandez plus, la valeur est ramenée au plafond plutôt
  que refusée. Sans `wait`, ou avec `wait: 0`, la recherche regarde une fois et répond
  immédiatement.
- **Vingt attentes simultanées par organisation.** Au-delà, `429 TOO_MANY_WAITS` :
  en général le signe d'une suite qui lance des recherches sans attendre leurs
  réponses.
- **`503 API_DRAINING` veut dire « relancez », pas « rien n'est arrivé ».** Quand nous
  remplaçons une instance, celle qui tenait votre attente vous la rend au lieu de
  couper la connexion. Relancez la même recherche : une autre instance la reprend.
  L'en-tête `Retry-After` dit combien attendre, et c'est toujours court. Une attente
  de trente secondes, la valeur par défaut, traverse un déploiement sans rien voir.

### Si vous lisez la liste d'une inbox

`GET /v1/inboxes/<id>/messages` n'a pas d'attente. Il vous faut alors une boucle de
guet :

```js
async function attendre(inboxId, versQui, delaiMs = 15000) {
  const fin = Date.now() + delaiMs;
  while (Date.now() < fin) {
    const messages = await api(`/v1/inboxes/${inboxId}/messages`);
    const trouve = messages.find((m) => m.to === versQui && m.status === 'parsed');
    if (trouve) return trouve;
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error(`aucun message pour ${versQui} en ${delaiMs} ms`);
}
```

Deux détails séparent un test fiable d'un test capricieux :

1. **Filtrez sur `to`**, pas sur le sujet ni sur « le dernier message ». C'est à cela
   que sert le sous-adressage, voir [Adresses d'inbox](adresses-inbox).
2. **Exigez `status === 'parsed'`**. Sinon le test lit parfois un message dont le code
   n'est pas encore extrait, et échoue une fois sur cinquante.

## Lire le champ `status` avant tous les autres

| `status` | Ce que ça veut dire | Ce que vous faites |
|---|---|---|
| `parsed` | Tout ce qui dérive du corps est renseigné | Lisez le message |
| `received` | Accepté et stocké, **pas encore analysé** | Rappelez la route, ou utilisez `wait` qui ne rend que du `parsed` |
| `quota_exceeded` | Refusé faute de plan. Rien n'a été stocké ni analysé | Achetez un pack, voir [Quotas et rétention](quotas-et-retention) |

L'analyse a lieu **après** l'acquittement SMTP. Pendant quelques millisecondes, un
message existe donc sans sujet, sans code et sans liens. Sans ce champ, « il n'y a pas
de code dans ce message » et « le code n'est pas encore extrait » se ressemblent trait
pour trait.

Un message refusé **omet les champs extraits** au lieu de les renvoyer vides :
`message.otp` vaut `undefined` et non un `null` sur lequel vous écririez une assertion
qui passe pour la mauvaise raison. Il porte à la place un objet `error` avec son code.

## La forme d'un message

```json
{
  "id": "…",
  "inbox": "ab12cd34",
  "receivedAt": "2026-08-07T09:14:02.481Z",
  "deliveryMs": 412,
  "status": "parsed",
  "from": { "address": "no-reply@exemple.test", "name": "Exemple" },
  "to": "signup+7c1e@ab12cd34.inbox.facteur.eu",
  "sizeBytes": 4821,
  "subject": "Votre code de confirmation",
  "text": { "body": "…" },
  "html": { "body": "…" },
  "otp": "418302",
  "links": [{ "href": "https://exemple.test/confirm?token=…", "text": "Confirmer" }],
  "codes": [],
  "headers": { "message-id": "…", "x-votre-en-tete": "…" },
  "extract": {},
  "attachments": [
    {
      "id": "…",
      "fileName": "facture-4821.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 86412,
      "checksum": "sha256:9f2c…",
      "stored": true,
      "url": "/v1/attachments/…"
    }
  ],
  "inlines": []
}
```

`deliveryMs` est le délai entre l'envoi et la réception, mesuré chez nous. `extract`
porte ce que vos propres pointeurs ont trouvé, voir [Pointeurs](pointeurs).

## Chercher par critères

```bash
curl -X POST https://api.facteur.eu/v1/messages/search \
  -H "Authorization: Bearer fk_votre_cle" \
  -H "content-type: application/json" \
  -d '{"inbox": "j3k9x2mq", "sentTo": "commande-4821", "subject": "confirmation"}'
```

**Les critères.** `sentTo`, `sentFrom`, `subject`, `body`, `otp`, `attachmentName`.
Ce sont des sous-chaînes, insensibles à la casse. `%` n'est pas un joker et ne cherche
que le caractère `%`. `otp` cherche le code extrait et lui seul : `{"body": "882314"}`
trouverait aussi un message qui ne fait que citer ce nombre.

**Les bornes.** `since`, `hasAttachment` et `hasOtp` rétrécissent la fenêtre au lieu
de s'ajouter aux alternatives.

**`match`.** `all` par défaut : tous les critères doivent correspondre. Passez `any`
pour qu'un seul suffise. Une borne n'entre jamais dans le `any`, sinon « depuis hier
ou de Camille » vous rendrait des messages d'avant-hier.

**Où chercher.** `inbox` pour une inbox, `workspace` pour un espace entier, ou ni
l'un ni l'autre pour tout ce que votre clé atteint.

**La pagination.** `limit` (50 par défaut, 200 au plus) et le `nextCursor` que la
réponse renvoie. Reprenez-le tel quel ; il est nul sur la dernière page, ce qui évite
de comparer un compte à une limite.

**Le transitoire.** Par défaut, la recherche ne rend que les messages `parsed`.
Passez `"status": "any"` pour voir aussi ceux qui viennent d'arriver.

### Deux refus qui vous font gagner une heure

- **Un critère inconnu est refusé**, pas ignoré. `subjet` au lieu de `subject` répond
  `400 CRITERIA_UNKNOWN` en nommant le champ. Ignoré, il vous aurait rendu *tous* les
  messages de l'inbox, et votre test serait passé pour la mauvaise raison jusqu'au
  jour où deux messages arrivent.
- **Une inbox qui n'est pas la vôtre répond `404`**, pas une liste vide. Une liste
  vide se lit « le message n'est pas arrivé » et envoie déboguer un pipeline qui
  fonctionne.

## Pièces jointes

`attachments` liste les vraies pièces jointes. `inlines` liste les images
référencées par le corps HTML (`cid:`), le plus souvent un logo de signature. Les deux
sont séparées pour que `attachments.length` ne change pas selon que l'expéditeur a
ajouté une bannière à sa signature.

**Vérifier le contenu sans télécharger le fichier.** Comparez le `checksum`, qui
porte l'empreinte SHA-256 du fichier reçu préfixée par `sha256:`, à celle de votre
fichier local :

```bash
sha256sum facture-4821.pdf
# 9f2c…  facture-4821.pdf
```

**Télécharger les octets** :

```bash
curl -H "Authorization: Bearer fk_votre_cle" \
  https://api.facteur.eu/v1/attachments/<id> -o facture.pdf
```

Les mêmes règles d'accès que pour le reste : une clé qui n'atteint pas l'espace de
travail contenant le message reçoit `404 ATTACHMENT_NOT_FOUND`, qu'une pièce jointe
existe réellement sous cet identifiant ou non.

**`stored` peut valoir `false`.** Le fichier est alors décrit (nom, taille, empreinte)
mais ses octets ne sont pas conservés, et `GET /v1/attachments/<id>` répond
`404 ATTACHMENT_NOT_STORED`. Le champ `reason` dit lequel des trois cas :

| `reason` | Ce que ça veut dire | Ce que vous faites |
|---|---|---|
| `PERFORMANCE_MODE` | L'inbox est réglée pour ne rien stocker | Changez le réglage de l'inbox si vous voulez les octets |
| `ATTACHMENT_TOO_LARGE` | Le fichier dépasse la taille permise par votre plan | Changez de plan, ou testez avec un fichier plus petit |
| `STORAGE_QUOTA_EXCEEDED` | L'enveloppe de stockage de l'organisation est pleine | Libérez de la place ou achetez un pack de stockage |

## Limites connues

- **Cinquante messages sans pagination sur la liste.**
  `GET /v1/inboxes/<id>/messages` renvoie les cinquante derniers et n'a pas de
  curseur. La recherche, elle, pagine.
- **Pas de recherche plein texte.** Les critères sont des sous-chaînes. Chercher un
  mot avec une casse ou un accent différent ne le trouvera pas.
- **Le filtre de la page d'inbox ne voit que les 50 derniers messages.** Il travaille
  sur ce que `GET /v1/inboxes/{id}/messages` a rendu, donc une valeur présente dans le
  51ᵉ message s'affiche comme absente. Passez par `POST /v1/messages/search`, qui
  pagine et cherche dans le corps.
