Aller au contenu
beeraw
Choix techniques ·

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 ou 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, et 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.

Combien coûte un message

Avant d’écrire quoi que ce soit, j’ai mesuré (pour une fois). Mail se pilote en AppleScript, qui parle aux applications par « événements Apple », et chaque aller-retour coûte cher. Sur une boîte de 20 000 messages, lire les seules métadonnées d’un message prend 0,65 s, et ce chiffre dépend de la taille de la boîte, pas du nombre de messages demandés. Le corps, lui, coûte 1,7 s pièce, toujours. Cinquante corps, c’est près d’une minute et demie.

Pour les métadonnées, il y a quand même une astuce : demander deux cents sujets en un seul événement plutôt que de boucler. C’est plusieurs fois plus rapide.

applescript
-- One Apple event, not two hundred
set subjects to subject of messages 1 thru 200 of theMailbox

Pour les corps, pas d’astuce : c’est plein tarif.

La recherche de Mail

AppleScript propose aussi une clause whose, évaluée à l’intérieur de Mail. Je me suis dit que ça devait être optimisé (j’y ai cru, oui).

applescript
tell application "Mail"
    set found to (messages of theMailbox whose subject contains "facture")
end tell

Sur 2 529 messages, 21 s pour chercher un mot dans les sujets. Avec content à la place de subject, la requête n’a jamais répondu : délai maximal dépassé. Et le pire, c’est que Mail est resté sourd à toutes les requêtes suivantes pendant plusieurs minutes. Bref, chercher dans toute l’archive par AppleScript, c’est non : ni les corps, ni même les sujets sur les grosses boîtes. Sur un compte entier, on parlerait d’heures.

La première version s’est donc contentée d’une fenêtre de messages récents, et chaque réponse dit combien de messages ont été parcourus, et sur combien (autant que l’outil avoue ce qu’il n’a pas regardé). Ça dépanne pour « qu’est-ce qui est arrivé cette semaine ». Pour « où est ce devis de 2023 », beaucoup moins.

134 326 messages, vraiment ?

Pour dimensionner un index, j’ai commencé par compter. Mail annonce 134 326 messages, tous dossiers additionnés, ce qui m’a paru beaucoup. Sur un compte Gmail : « Tous les messages » 30 821, « Important » 21 729, la boîte de réception 21 721. Ça se recouvre largement : pour Mail, les libellés Gmail sont des dossiers, 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é dans un coin, il allait resservir.

Ce qu’il y a sur le disque

Mail range chaque message dans un fichier .emlx. Le format n’est pas documenté, mais il est simple : une ligne avec le nombre d’octets, le message brut, puis un petit fichier de propriétés.

python
def read_raw_message(path):
    with open(path, "rb") as handle:
        length = int(handle.readline().strip())
        return handle.read(length)      # RFC822, which Python can parse

Premier coup de chaud : sur 51 794 fichiers, 37 039 sont des .partial.emlx. Si « partiel » voulait dire « sans le corps », la recherche plein texte ne couvrirait que 29 % de l’archive. J’ai ouvert quarante fichiers de chaque type pour en avoir le cœur net : les partiels ont tous un corps (40 sur 40), et même un corps plus gros en médiane que les complets, 4 615 octets. « Partiel » veut juste dire que les pièces jointes n’ont pas été téléchargées.

Deuxième coup de chaud, plus sérieux : la boîte de réception et « Important », plus de 21 000 messages chacune d’après Mail, n’ont aucun fichier sur le disque. Tout est dans « Tous les messages ». Mail ne stocke un message qu’une fois par compte, et les libellés ne sont que des vues. Le disque me donne donc le contenu, mais pas la boîte où se trouve le message.

L’index que Mail tient déjà

Cette info existe forcément quelque part, puisque Mail l’affiche. Elle est dans MailData/Envelope Index, une base SQLite de 147 Mo et 55 tables 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 m’évite de parser cinquante mille fichiers pour des métadonnées déjà rangées. Je la copie avec son journal d’écriture (sinon les changements récents manquent) et je l’ouvre en lecture seule. Les rôles se répartissent d’eux-mêmes : l’index de Mail n’a pas le plein texte, et les fichiers n’ont pas les dossiers.

Évidemment, il y avait un piège. La table labels associe un message à un dossier, et je l’ai d’abord prise pour argent comptant. Sauf qu’elle compte 85 573 lignes pour environ 48 000 messages, et que pour mon message témoin elle liste trois dossiers… mais pas celui où le fichier se trouve vraiment. La boîte principale est une colonne de messages, les dossiers en plus sont dans labels. Pour avoir l’appartenance complète, il faut l’union des deux :

sql
SELECT ROWID AS message, mailbox FROM messages WHERE deleted = 0
UNION ALL
SELECT message_id, mailbox_id FROM labels

Rien dans le schéma ne le dit, et pour cause : il n’est pas documenté.

Vérifier avant d’indexer

Ce schéma est interne et peut changer à chaque version de macOS. L’indexeur commence donc par vérifier que ma lecture tient toujours, et refuse de démarrer si quelque chose cloche :

sortie
[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 ligne qui m’intéresse le plus, c’est la quatrième. Elle reconstitue l’appartenance par l’union des deux sources, puis compare dossier par dossier avec les décomptes de Mail. Si je me suis trompé sur le modèle, je le sais tout de suite, au lieu de découvrir un jour qu’il manque des messages dans l’index.

Indexé, pas stocké

Restait à choisir ce qu’on garde. J’ai repris une distinction des moteurs de recherche : un champ peut être indexé, donc cherchable, sans être stocké.

sql
CREATE VIRTUAL TABLE messages_fts USING fts5(
    subject, sender, recipients, attachments, body,
    content='', contentless_delete=1
);

content='' crée une table sans contenu : SQLite construit l’index inversé et jette le texte. Le corps des messages est cherchable, mais il n’est conservé nulle part. Pour lire un message, on repasse par Mail.

Le reste du modèle sépare le message de ses emplacements (un message, N emplacements), ce qui règle au passage la duplication Gmail. La clé, c’est l’en-tête Message-ID. L’identifiant interne de Mail, lui, change dès qu’on déplace le message.

Le retour du chiffre gonflé

Index terminé, j’ai additionné les emplacements : neuf messages d’écart avec le total annoncé au début, et ces neuf-là sont arrivés entre les deux mesures. Le chiffre qui m’avait paru faux comptait simplement autre chose, et le retrouver par deux chemins différents m’a bien rassuré sur le modèle.

Côté temps, pour chercher « attestation urssaf » :

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

3 ms, c’est agréable, mais le plus utile reste la portée : 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 arrivé : « prépare trois mails, je les relis, tu les envoies ». AppleScript sait préparer un brouillon, mais pour l’envoyer, c’est une autre histoire :

applescript
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 connaît que l’outgoing message, l’objet qui se cache derrière une fenêtre de rédaction. Un brouillon rangé dans un dossier n’en est pas un.

J’ai essayé trois voies, sans succès. 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. Le déplacer vers la boîte d’envoi ne déclenche rien du tout. Et d’après les forums de scripteurs, le problème ne date pas d’hier : même question, même erreur, avec pour seule solution le pilotage du clavier via l’accessibilité, qui demande une permission de plus et casse au moindre changement d’interface.

Le brouillon qui revient

Contournement suivant : lire le brouillon, réémettre son contenu, supprimer l’original. La suppression réussissait (Mail me le confirmait) et le brouillon revenait dix secondes plus tard. En fait, je supprimais pendant que le compte synchronisait l’envoi que je venais de faire, et le serveur Gmail écrasait ma suppression au tour suivant. Avec 8 s d’attente après l’envoi, la même suppression tient. Le code était bon, c’était juste le mauvais moment (j’avoue que celui-là m’a rendu modeste).

Vingt secondes d’attente par envoi pour compenser une limite d’API, ça marche, mais je n’en étais pas très fier. À force de chercher comment supprimer proprement ce brouillon, j’ai fini par me demander pourquoi il existait. Et la réponse est simple : 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. Plus de brouillon dans Mail, donc plus rien à supprimer ni de course avec le serveur.

Bonus : le fichier est figé et horodaté, donc si une virgule bougeait entre la relecture et l’envoi, ça se verrait. Et pour les pièces jointes, je demande un mail avec un décompte joint, le fichier est pris sur le disque et le message part. Fini les allers-retours par Drive.

Le bug qui a envoyé un mail

Dernier épisode. 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 écrire dedans.

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, et la position 1 pointait sur un vieux brouillon. Le code a écrit la réponse dedans, et l’a envoyé. Heureusement, j’étais le destinataire et c’était un test, mais le même mécanisme aurait visé n’importe quel brouillon en attente. Maintenant, la fenêtre est retrouvée par son identifiant, en comparant la liste avant et après.

À ne pas mettre entre toutes les mains

Je préfère le dire clairement : 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 aussi l’accorder pour Messages, l’historique du navigateur et les données des autres applications.

Autre point, moins visible : le contenu des messages revient brut à l’agent. Un mail peut donc contenir du texte qui ressemble à une instruction, et n’importe qui peut vous écrire sans rien demander à personne.

Si vous voulez vous en servir pour de vrai :

  • essayez d’abord sur un compte secondaire ;
  • gardez la confirmation obligatoire sur les envois : chaque expédition reste délibérée, et vous voyez ce qui partirait ;
  • n’accordez l’accès disque que si la recherche indexée vous est vraiment nécessaire, et retirez-le ensuite (ce qui est déjà indexé reste cherchable) ;
  • demandez-vous honnêtement si l’agent doit avoir le droit d’envoyer, ou seulement celui de préparer.

Enfin, le serveur est fait pour tourner en local, pour une personne, sur sa machine. Il n’a pas été pensé pour plusieurs utilisateurs.

Le code

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.

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.

Racontez-moi votre projet

Dites-moi en quelques mots ce que vous voulez construire. Vous écrivez au développeur, pas à un formulaire anonyme.