Chercher dans quatorze ans de mails
Parti d'une frustration bête : un connecteur Gmail qui ne sait pas joindre un fichier. Le chantier a fini en moteur de recherche. Récit d'un projet avec moult difficultés.
J’utilise Claude au quotidien. Globalement je trouve que ça me fait gagner un temps fou. Que ce soit pour coder, remplir des documents, faire des tâches rébarbatives. Pour aller plus loin j’ai utilisé le connecteur Gmail. Au début ça a l’air sympa, mais on se confronte vite aux limites : les pièces jointes. Quand ce n’est pas la taille du fichier, c’est le format qui pose problème, notamment les fichiers Excel.
Or dans le BTP (parce que oui je suis développeur entre autres casquettes dans le BTP), les pièces jointes c’est la norme. Quand on se connecte à Gmail dans Claude, il va aussi se connecter à Google Drive, toujours avec des limites. Donc à la fin, c’est moi qui allais récupérer le fichier et l’uploader ici, le joindre à tel mail là-bas. Bref, pas hyper fluide tout ça.
L’autre hic, c’est que Claude ne lit qu’une boîte mail Gmail. Perso j’en ai quatre. Deux pro, une perso & une legacy. Une boîte mail Gmail, deux Google Workspace et une Microsoft Exchange (enfin je crois que c’est leur protocole). J’utilise le client Mail par défaut, dans mes années linuxiennes de bureau, j’utilisais Thunderbird. J’ai aussi utilisé d’autres clients, le constat est toujours le même, la recherche, c’est compliqué. Alors avec quatorze ans de mails, on est mal barré.
Le truc cool avec un client mail “lourd”, c’est que tout est sur disque. Donc les données sont là. L’application sait envoyer et recevoir des mails, son problème c’est la recherche. Elle est limitée aux messages les plus récents. Pourquoi ? Parce que lire un message, c’est long.
Première mesure : le prix d'un message
Le premier chiffre dépend de la taille de la boîte où on cherche, pas du nombre de messages demandés. Le second ne dépend de rien : 1,7 s pièce, toujours. Il y a quand même une astuce.
-- Un seul événement Apple, pas deux cents
set subjects to subject of messages 1 thru 200 of theMailbox
AppleScript parle aux applications par « événements Apple », et chaque aller-retour coûte cher. Deux cents sujets d’un coup, c’est plusieurs fois plus rapide qu’une boucle. Ça marche pour les métadonnées ; les corps, eux, restent au tarif plein.
Deuxième mesure : la recherche native ne tient pas
AppleScript propose une clause whose, évaluée à l’intérieur de Mail. On peut espérer que ce soit optimisé.
tell application "Mail"
set found to (messages of theMailbox whose subject contains "facture")
end tell
Sur 2 529 messages, 21 s. La même requête avec content à la place de subject n’a jamais répondu : délai maximal dépassé, et — c’est le vrai enseignement — Mail est resté sourd à toutes les requêtes suivantes pendant plusieurs minutes. Une recherche mal placée n’est pas juste lente, elle bloque l’application.
Chercher dans toute l’archive par AppleScript. Ni les corps, ni même les sujets sur les grosses boîtes. Sur un compte entier, on parlerait d’heures.
Premiers tests de recherche
La première version a donc assumé sa limite : chercher dans une fenêtre bornée de messages récents. Chaque réponse indique combien de messages ont été parcourus, et sur combien. Un outil qui ment sur son périmètre est pire qu’un outil limité.
Ça marche. Ça reste frustrant : bien pour « qu’est-ce qui est arrivé cette semaine », inutile pour « où est ce devis de 2023 ».
Le chiffre qui ne collait pas
En comptant le corpus pour dimensionner l’index, je tombe sur un total bizarre. Sur un compte Gmail : « Tous les messages » 30 821, « Important » 21 729, la boîte de réception 21 721. Ça se recouvre largement — les libellés Gmail sont des dossiers aux yeux de Mail, et un message est compté autant de fois qu’il porte d’étiquettes.
Le vrai corpus est donc bien plus petit. J’ai gardé ce chiffre gonflé de côté, il servira de test de cohérence plus tard.
Le stockage sur disque, et deux fausses alertes
Mail garde chaque message dans un fichier .emlx. Format simple et non documenté : une ligne avec le nombre d’octets, le message brut, puis un petit fichier de propriétés.
def read_raw_message(path):
with open(path, "rb") as handle:
length = int(handle.readline().strip())
return handle.read(length) # du RFC822, que Python sait parser
Premier sursaut : sur 51 794 fichiers, 37 039 sont des .partial.emlx. Si « partiel » voulait dire « sans le corps », l’indexation plein texte ne couvrirait que 29 % de l’archive.
Vérification sur quarante fichiers de chaque type, plutôt que de supposer : les partiels ont tous un corps, et un corps plus gros en médiane que les fichiers complets. « Partiel » veut dire « pièces jointes non téléchargées ». Fausse alerte.
Second sursaut, plus sérieux : la boîte de réception et « Important », plus de 21 000 messages chacun d’après Mail, n’ont aucun fichier sur le disque. Tout est dans « Tous les messages ».
Mail ne stocke un message qu’une seule fois par compte. Les libellés ne sont pas des dossiers sur le disque, juste des vues. Le disque donne donc le contenu, mais pas la boîte où se trouve un message.
L'index que Mail tient déjà
L’info manquante existe forcément quelque part, puisque Mail l’affiche. Elle est dans MailData/Envelope Index, une base SQLite que Mail tient pour son propre usage : une table messages, une table labels, les sujets et les adresses normalisés, les destinataires, les pièces jointes.
La lire évite de parser cinquante mille fichiers pour des métadonnées déjà structurées. On la copie — avec son journal d’écriture, sinon les changements récents manquent — et on l’ouvre en lecture seule. Le partage des rôles se fait tout seul : l’index de Mail n’a pas de plein texte, les fichiers n’ont pas les dossiers.
Le piège des libellés
La table labels associe un message à un dossier. Forcément, on la prend pour la source de vérité. Elle ne l’est qu’à moitié : sur mon message témoin, elle liste trois dossiers, et pas celui où le fichier se trouve vraiment.
La boîte principale est une colonne de messages, les dossiers en plus sont dans labels. L’appartenance complète, c’est l’union des deux :
SELECT ROWID AS message, mailbox FROM messages WHERE deleted = 0
UNION ALL
SELECT message_id, mailbox_id FROM labels
Aucune des deux sources ne suffit seule, et rien dans le schéma ne le dit — il n’est pas documenté.
Refuser de construire plutôt que construire faux
Justement : ce schéma est interne, non documenté, et peut changer à chaque version de macOS. Donc l’indexeur vérifie avant de travailler, et refuse de démarrer si ça cloche.
[ok] expected tables present
[ok] mailbox urls parse — 59/59
[ok] message ids map to .emlx files — 100%
[ok] membership matches Mail's counts — 14/14 mailboxes
[ok] RFC Message-ID readable from files — 50/50
[ok] body text readable from files — 50/50
[ok] date_received looks like a unix timestamp
La quatrième ligne est la seule qui compte vraiment. Elle reconstitue l’appartenance par l’union des deux sources, puis compare dossier par dossier avec les décomptes de Mail. Si ma lecture du modèle est fausse, elle le dit. Un index incomplet en silence serait bien pire qu’un refus de démarrer.
Indexé, pas stocké
Reste à choisir ce qu’on garde. La distinction vient des moteurs de recherche : un champ peut être indexé — donc cherchable — sans être stocké.
CREATE VIRTUAL TABLE messages_fts USING fts5(
subject, sender, recipients, attachments, body,
content='', contentless_delete=1
);
content='', c’est une table sans contenu : SQLite construit l’index inversé et jette le texte. Le corps devient cherchable sans jamais être conservé. Pour lire un message, on repasse par Mail.
Le reste du modèle sépare le message de ses emplacements, ce qui absorbe la duplication Gmail au lieu de la subir : un message, N emplacements. La clé durable, c’est l’en-tête Message-ID, pas l’identifiant interne de Mail — celui-là change dès qu’on déplace le message.
Le chiffre gonflé, revenu en juge
Index terminé, la somme des emplacements retombe à neuf messages près du total annoncé au début — l’écart, ce sont les mails arrivés entre les deux mesures. Le chiffre qui paraissait faux ne l’était pas, il comptait juste autre chose. Le retrouver depuis deux sources indépendantes, c’est la meilleure preuve que le modèle est bon.
| Chercher « attestation urssaf » | Portée | Temps |
|---|---|---|
Clause whose de Mail |
1 dossier | > 120 s |
| Fenêtre bornée récente | 200 messages | 17 s |
| Index local | tout le corpus | 3 ms |
Le gain de temps est spectaculaire, mais c’est la portée qui compte : la recherche trouve maintenant des mots dans le corps de messages vieux de plusieurs années.
Deuxième chantier : envoyer un brouillon
Je pensais avoir fini. Puis un usage tout bête est apparu : « prépare trois mails, je les relis, tu les envoies ». Préparer un brouillon, AppleScript sait faire. L’envoyer, non.
set m to first message of drafts mailbox
send m
--> Mail got an error: … doesn't understand the "send" message. (-1708)
La commande send ne comprend qu’un outgoing message, l’objet derrière une fenêtre de rédaction. Un brouillon rangé dans un dossier n’en est pas un.
Ouvrir le brouillon le transforme bien en fenêtre de rédaction, donc en objet envoyable. Mais le délai est imprévisible : parfois deux secondes, parfois plus d’une minute, et deux fois sur trois pas dans la fenêtre où je l’attendais. Inutilisable. Le déplacer vers la boîte d’envoi, l’autre idée séduisante, ne déclenche rien du tout.
Les forums de scripteurs confirment que le mur est réel et ancien : même question, même erreur, et pour seule solution le pilotage du clavier via l’accessibilité. Ça demande une permission de plus et ça casse au moindre changement d’interface.
Gmail annule votre suppression
Contournement du premier tour : lire le brouillon, réémettre son contenu, supprimer l’original. La suppression réussissait — Mail le confirmait — et le brouillon revenait dix secondes plus tard.
C’est le moment qui était faux, pas le procédé : supprimer pendant que le compte synchronise l’envoi qu’on vient de faire, c’est se faire écraser par le serveur au tour d’après. Huit secondes plus tard, la même suppression tient. Le genre de bug qui rend modeste — rien dans le code n’était faux, seul l’instant l’était.
La bonne réponse était de sortir de Mail
Vingt secondes d’attente par envoi pour compenser une limite d’API, ça marche mais ça ne se défend pas. Je me suis demandé si je ne me trompais pas de question : je cherchais comment supprimer proprement ce brouillon, alors que la vraie question c’était pourquoi il existait. Prise comme ça, elle disparaît — il suffit de ne plus écrire les brouillons dans Mail.
Un brouillon devient un fichier .eml autonome, pièces jointes embarquées, que macOS ouvre comme un vrai message au double-clic. On le relit, on l’envoie, le fichier part aux archives. Aucun brouillon dans Mail, donc rien à supprimer, donc plus de course avec le serveur.
Le fichier est même un meilleur témoin que le brouillon : figé, horodaté, comparable. Si une virgule bougeait entre la relecture et l’envoi, ça se verrait. Et il embarque ses pièces jointes : je demande un mail avec un décompte joint, le fichier est pris sur le disque, le message part. Plus d’aller-retour par Drive.
Le bug qui a envoyé un mail
Un dernier épisode, parce qu’il est instructif. Pour qu’une réponse reste dans son fil, il faut passer par la commande reply de Mail : on ne peut pas poser l’en-tête In-Reply-To soi-même. Elle ouvre une fenêtre de rédaction, qu’il faut ensuite retrouver pour y écrire.
Ma première version la retrouvait par sa position, la première de la liste. Sauf que Mail garde ses messages sortants même après envoi : la position 1 pointait sur un vieux brouillon. Le code a écrit la réponse dedans, et l’a envoyé. C’était moi le destinataire et le contenu était un test, mais le mécanisme aurait visé n’importe quel brouillon en attente. Correction : identifier la fenêtre par son identifiant, en comparant la liste avant et après.
À ne pas mettre entre toutes les mains
Il faut le dire, c’est le revers de tout ce qui précède : cet outil ouvre beaucoup de portes. L’autorisation d’automatisation donne à un agent le droit de lire, d’envoyer, de déplacer et de supprimer sur tous les comptes configurés, sans rien redemander. Et l’index a besoin de l’accès complet au disque, que macOS ne sait pas restreindre à un dossier : l’accorder, c’est l’accorder aussi pour Messages, l’historique du navigateur, les données des autres applications.
Il y a plus sournois : le contenu des messages revient brut à l’agent. Un mail peut donc contenir du texte qui ressemble à une instruction, et personne n’a jamais eu besoin d’autorisation pour vous écrire.
Essayer d’abord sur un compte secondaire. Garder la confirmation obligatoire sur les envois : ça rend chaque expédition délibérée et ça affiche ce qui partirait. N’accorder l’accès disque que si la recherche indexée est vraiment nécessaire, et le retirer ensuite — ce qui est déjà indexé reste cherchable. Et se demander honnêtement si un agent doit avoir le droit d’envoyer, ou seulement celui de préparer.
Le serveur est fait pour tourner en local, pour une personne, sur sa machine. Ce n’est pas un service, il n’a pas été pensé pour plusieurs utilisateurs.
github.com/beeraw/mcp-mail-macos — serveur MCP en Python, licence MIT. Vingt-cinq outils, un indexeur, une suite de tests qui tourne sans Mail. Le README documente les limites d’AppleScript et les mesures derrière chaque décision.
- Mesurer avant d’architecturer. La plupart des mesures ont fermé une porte. Deux minutes chacune ; le découvrir après avoir écrit l’indexeur, c’était une journée.
- Un chiffre qui ne colle pas est un cadeau. Les messages fantômes ressemblaient à une erreur. Ils décrivaient le modèle de données de Gmail, et ils ont fini par servir de preuve.
- Échouer bruyamment plutôt que produire du faux. Sur un format non documenté, la vérification au démarrage n’est pas du zèle : c’est ce qui distingue un outil d’un piège.
- Contourner une limite, c’est parfois se tromper de problème. Vingt secondes d’attente pour survivre à une synchro, c’était une rustine élégante sur une mauvaise idée. Sortir les brouillons de Mail a supprimé la question.
- Une limite d’outil coûte plus cher qu’on ne croit. Une pièce jointe impossible à joindre, c’est deux minutes. Multipliées par tous les mails d’une semaine, ça justifie de reconstruire l’outil.
- Tester l’écriture pour de vrai. Les deux bugs sérieux du projet n’étaient visibles qu’en envoyant réellement un message, dans un état que seule l’utilisation répétée avait produit.
Serveur MCP en Python, sans dépendance hors bibliothèque standard à part le SDK mcp. Index SQLite avec FTS5, corps indexés mais non stockés. Mesures relevées sur un MacBook Pro M4 Pro, macOS 27, Mail 16, comptes Gmail, IMAP et Exchange.