mailbox: Tes Boîtes Mail en API HTTP et Serveur MCP Qui Ne Stocke Aucun Octet

J’ai cinq comptes mail. Un Gmail perso que je traîne depuis l’adolescence, une adresse pro, une boîte jetable pour les inscriptions auxquelles je ne fais pas confiance, une adresse sur le domaine de ce blog, et une cinquième que j’avais complètement oubliée jusqu’à retrouver le mot de passe dans un fichier texte. Cinq boîtes, cinq jeux d’identifiants, cinq onglets, cinq compteurs de non-lus qui me mentent sur l’état de ma vie. Toutes les applis de “boîte unifiée” de cette planète veulent résoudre ça en prenant la garde de mes cinq comptes, en les synchronisant dans leur cloud, en passant mon courrier dans leur “catégorisation intelligente par IA” et en me rendant une jolie interface en échange de la seule chose que je possède et qui mérite vraiment d’être protégée: le contenu de ma boîte.

Non. Va te faire foutre. Mon mail contient des relevés bancaires, des codes de récupération 2FA, des trucs médicaux, des contrats, et assez de données personnelles pour me niquer une décennie si ça fuite. Je ne file pas ça à une startup “inbox zero” gavée de capital-risque pour qu’elle le laisse s’échapper par l’injection SQL de son stagiaire dans dix-huit mois. Alors j’ai construit mailbox: un conteneur, un fichier YAML, une API HTTP et un serveur MCP sur le même port, zéro octet de mon courrier stocké ailleurs que sur les serveurs mail où il vit déjà.

Comment Toute Boîte Unifiée Te Baise

Tous les produits de “boîte unifiée” suivent le même script, et c’est le même script que toute boîte de SaaS applique à absolument n’importe quoi:

  • Étape un: te convaincre de livrer les identifiants IMAP/OAuth de chaque compte que tu possèdes. C’est tout le modèle économique: l’agrégation est le produit.
  • Étape deux: tout synchroniser dans leur base. Il existe maintenant une deuxième copie de tout ton historique de courrier sur une infra que tu ne contrôles pas et que tu ne peux pas auditer.
  • Étape trois: l'”enrichir”. Résumés par IA, catégorisation intelligente, scores de “boîte prioritaire”: tout ça veut dire passer ta correspondance privée dans un modèle que tu n’as pas choisi, hébergé quelque part où tu ne peux pas regarder.
  • Étape quatre: tout part en fumée dans une fuite. Pas “si”, mais “quand”. Un agrégateur de courrier centralisé, c’est une cible unique, bien grasse, à très haute valeur. Une fuite et tous les comptes que tu as branchés tombent d’un coup, pas étalés sur cinq fournisseurs avec cinq équipes sécu différentes, mais tout, en une fois, dans un seul dump, dans un seul post de forum.

Et même en mettant le risque de fuite de côté, il y a une objection plus simple: pourquoi est-ce qu’un outil qui lit mon courrier aurait besoin d’une base de données? IMAP est déjà la base. Le serveur mail stocke déjà le courrier, l’indexe et sert des résultats de recherche. Empiler une copie par-dessus ne rend rien plus rapide ni plus sûr, ça crée juste une chose de plus qui peut fuiter, se désynchroniser, ou se faire réclamer par un procureur.

Donc je n’ai pas construit un moteur de synchro. J’ai construit un proxy sans état qui ouvre une connexion, fait exactement la chose que tu lui as demandée, et raccroche.

Tu Te Connectes, Tu Fais Le Boulot, Tu Crèves

mailbox (le démon s’appelle mailboxd) est une appli FastAPI, avec imaplib et smtplib de la stdlib en dessous, qui transforme autant de comptes IMAP/SMTP que tu lui balances en une seule API HTTP et un seul serveur MCP, sur exactement le même port. Le transport MCP est monté directement dans la même instance de l’appli FastAPI, en sous-route:

app.router.routes.append(Mount(_MCP_PATH, app=mcp_manager.handle_request))
app.add_middleware(_MCPSlashNormalizer)
app.add_middleware(_BearerASGI, cfg=cfg)

Même processus, même port, même porte d’authentification bearer par-dessus les deux. Pas de deuxième démon, pas de deuxième port à ouvrir dans le pare-feu, pas de passerelle stdio tournant comme processus fils et capable de crever indépendamment de la couche HTTP. Un seul pool de workers uvicorn sert à la fois les requêtes REST et les sessions MCP streamable-HTTP depuis la même socket.

La partie qui m’intéresse vraiment, c’est le modèle de stockage, qui est: il n’y en a pas. Chaque appel IMAP passe par un context manager qui ouvre une connexion fraîche, se connecte, fait exactement l’opération demandée et démolit la connexion dans un bloc finally, que ça ait marché ou pas:

@contextmanager
def _connect(cfg: ImapConfig) -> Iterator[imaplib.IMAP4]:
    if cfg.tls == "ssl":
        conn: imaplib.IMAP4 = imaplib.IMAP4_SSL(
            cfg.host,
            cfg.port,
            ssl_context=ssl.create_default_context(),
            timeout=_SOCKET_TIMEOUT_SECONDS,
        )
    else:
        conn = imaplib.IMAP4(cfg.host, cfg.port, timeout=_SOCKET_TIMEOUT_SECONDS)
    try:
        if cfg.tls == "starttls":
            conn.starttls(ssl_context=ssl.create_default_context())
        try:
            conn.login(cfg.username, cfg.password)
        except imaplib.IMAP4.error as e:
            raise ImapError(f"login failed: {e}") from e
        yield conn
    finally:
        try:
            conn.logout()
        except (imaplib.IMAP4.error, OSError):
            ...  # logged, then the socket gets shut down

Absolument chaque fonction IMAP: list_folders, list_messages, search_messages, fetch_message, fetch_raw, mark_seen: emballe son boulot dans un bloc with _connect(cfg) as conn: tout neuf. Depuis la v0.6.0 ce contexte vérifie la chaîne de certificats et le nom d’hôte sur ssl comme sur starttls, sans aucun réglage nulle part pour le désactiver, donc un serveur mail privé veut dire monter ton bundle de CA et pointer SSL_CERT_FILE dessus, pas hausser les épaules et accepter n’importe quoi. Les sockets abandonnent au bout de 30 secondes au lieu de pendre indéfiniment sur un serveur qui a cessé de répondre. Pas de pooling, pas de keep-alive, aucun thread de synchro en arrière-plan qui lit ton courrier que tu l’aies demandé ou non. Il y a désormais un watcher optionnel, mailboxd watch, qui tient un IMAP IDLE ouvert et POSTe vers des webhooks signés HMAC-SHA256 quand du courrier arrive: en-têtes et verdicts de spam seulement, jamais les corps. C’est un processus séparé derrière un profil Compose, donc il tourne parce que tu l’as lancé. SMTP fait pareil, avec un context manager simple autour de smtplib.SMTP_SSL ou smtplib.SMTP: ouvrir, authentifier, envoyer, fermer, à chaque fois que /send est frappé.

Il n’existe aucune table de messages nulle part dans ce code. Pas de Redis, pas de moteur de synchro, pas de deuxième copie de ton courrier. La v0.6.0 a enfin reconnu un fichier SQLite, à /var/lib/mailboxd/state.db, qui tient exactement les six choses qui ne peuvent pas vivre sur un serveur IMAP: le journal d’audit, les compteurs d’envoi horaires, les points de reprise du polling, les revendications de brouillon, les baux du watcher et la file de livraison des webhooks. Six tables, et aucune n’a de colonne pour le corps d’un message. Monte un volume sur son répertoire, sinon tu perds tes limites de débit et ta piste d’audit au redémarrage, et voilà tout le rayon de souffle. Le courrier lui-même ne s’écrit toujours jamais nulle part: tue le conteneur au milieu d’un fetch et il ne reste rien qui t’appartienne à récupérer, parce que la seule copie a toujours été sur le serveur mail. Ce n’est pas une case de conformité collée là pour l’article: c’est toute l’architecture. Zéro octet de ton courrier stocké n’est pas une punchline marketing, c’est ce que tu obtiens quand tu refuses d’écrire un moteur de synchro.

Un Fichier YAML, Autant De Comptes Que Tu Veux

Mes cinq boîtes vivent dans un seul fichier de config, et chaque mailbox déclare la combinaison de imap et smtp dont il a réellement besoin: un compte en lecture seule peut sauter SMTP entièrement, un relais d’envoi seul peut sauter IMAP:

auth:
  tokens:
    - name: agent
      token: "long-random-token-1"
      scopes: [read, draft, organize, send]
      mailboxes: "*"
mailboxes:
  - name: personal
    description: "Gmail"
    imap:
      host: imap.gmail.com
      port: 993
      tls: ssl
      username: [email protected]
      password: "app-password"
      default_folder: INBOX
    smtp:
      host: smtp.gmail.com
      port: 465
      tls: ssl
      username: [email protected]
      password: "app-password"
      from_address: "Me <[email protected]>"
  - name: work
    imap:  { host: mail.work.com, port: 143, tls: starttls, username: me, password: "...", default_folder: INBOX }
    smtp:  { host: mail.work.com, port: 587, tls: starttls, username: me, password: "...", from_address: [email protected] }

Pydantic valide tout au chargement: name doit correspondre à [a-zA-Z0-9_-]+ et être unique (il devient un segment d’URL et c’est la poignée qu’un agent passe comme argument MCP mailbox), chaque mailbox a besoin d’au moins l’un des deux imap/smtp sinon le chargement de la config se casse net, et IMAP tombe par défaut sur 993/ssl tandis que SMTP tombe sur 587/starttls, sauf si ton fournisseur est bizarre. N’importe quelle valeur texte là-dedans peut s’écrire ${SOME_VAR} et se résout depuis l’environnement au démarrage, une variable manquante étant une erreur de démarrage dure et pas un mot de passe vide, donc les identifiants n’ont aucune raison de vivre dans le YAML. La boîte que tu cibles à chaque appel se choisit par ce même name, ou, sur MCP et dans le filtre /inbox, par l’adresse mail réelle du compte. L’argument MCP mailbox résout l’un ou l’autre, donc un agent n’a pas à se souvenir si tu l’as appelé “personal” ou “gmail” il y a six mois.

L’API HTTP

Trente et une routes, toutes JSON en entrée, JSON en sortie, avec des erreurs de la forme {"error": {"code", "message"}, "detail": "..."}: l’objet error est celui sur lequel tu fais tes correspondances, et le detail plat est resté là pour ne rien casser chez ce qui parsait l’ancienne forme. Ces dix-là sont celles que tu vas vraiment taper:

  • GET /health: toujours ouverte, pas de bearer, pointe ta sonde de liveness dessus et passe à la suite.
  • GET /mailboxes: liste les boîtes que ton token voit, leurs adresses, leurs identités d’expéditeur et les protocoles dont chacune dispose.
  • GET /inbox: c’est celle que tu veux vraiment. Elle part en éventail sur chaque boîte ayant IMAP en parallèle via un ThreadPoolExecutor, lance la même recherche structurée sur chacune, fusionne les résultats du plus récent au plus ancien et étiquette chaque message avec le compte d’où il vient. Un compte mort atterrit dans un tableau errors au lieu de faire exploser tout l’appel: neuf boîtes debout et une à terre te rendent quand même les neuf autres.
  • GET /mailboxes/{name}/folders: liste les dossiers IMAP d’un compte.
  • GET /mailboxes/{name}/messages: les en-têtes du plus récent au plus ancien, avec un paramètre search IMAP brut si tu veux lui passer un UNSEEN directement.
  • GET /mailboxes/{name}/search: les mêmes filtres structurés que /inbox (from/to/recipient/subject/body/text/since/before, les drapeaux Seen/Flagged/Answered, des bornes de taille, les mots-clés IMAP, has_attachments et label), limités à un compte, avec pagination par cursor opaque et un since_cursor qui ne rend que ce qui est arrivé depuis ton dernier appel.
  • GET /mailboxes/{name}/messages/{uid}: un message, entièrement décodé, avec un reader=true optionnel.
  • DELETE /mailboxes/{name}/messages/{uid}: déplace le message vers la corbeille, avec UID MOVE là où le serveur l’a et avec COPY plus \Deleted plus UID EXPUNGE là où il ne l’a pas. Avant, ça posait le drapeau et faisait un expunge sur place, ce qui était une excellente façon pour un agent de supprimer définitivement ton courrier au premier appel d’outil foireux. La vraie suppression, c’est permanent=true, et elle ne marche que depuis l’intérieur de la corbeille et seulement pour un token portant la portée delete, donc il faut la vouloir deux fois.
  • POST /mailboxes/{name}/messages/{uid}/seen: pose ou retire le drapeau Seen.
  • POST /mailboxes/{name}/send: envoi via SMTP, multipart/alternative si tu lui donnes un corps texte et un corps HTML.

Les vingt et une autres sont arrivées avec la v0.6.0 et parlent plus d’écriture que de lecture. La source brute et les pièces jointes individuelles en téléchargement (.../messages/{uid}/raw, .../attachments/{part_id}), la recherche de conversations par Message-ID et References (.../thread, .../threads), et des réponses dérivées côté serveur sur .../messages/{uid}/reply, où les destinataires, le sujet avec Re:, In-Reply-To et References sont tous extraits de l’original, donc un client ne peut pas casser le threading en les fournissant lui-même. Puis les cinq routes de brouillon, celles qui comptent si tu laisses un agent approcher ce truc: il rédige le message, le laisse dans ton dossier Drafts, et c’est toi qui décides. Plus seen et delete en lot, les labels, les déplacements, la création de dossiers, les identités, les stats par dossier, et quatre routes d’admin: le health en profondeur, le journal d’audit, et la file de livraison des webhooks avec le replay dessus.

Des UID partout, jamais des numéros de séquence, donc les identifiants restent stables pendant que la boîte bouge sous tes pieds entre deux appels. Et l’endpoint d’envoi ne bâcle pas ça: il pose un en-tête Date correct, dérive le domaine du Message-ID de l’adresse de l’expéditeur pour qu’il ne contredise pas l’en-tête From:, ce qui est un piège classique des filtres antispam et le genre de détail que tu ne règles que si tu as réellement perdu du courrier à cause de lui. Avant, il falsifiait aussi un User-Agent Thunderbird par-dessus, sur la théorie qu’en être dépourvu paraît légèrement louche aux heuristiques des fournisseurs; la v0.6.0 l’a retiré. Ce qu’il fait à la place, c’est déposer la copie envoyée: à un envoi réussi, le message, Bcc intact, est ajouté à ton dossier Sent, et si l’ajout échoue tu reçois warnings: ["sent_copy_failed"], pas un envoi en échec que tu as déjà fait. Gmail dépose son courrier sortant lui-même, donc une boîte Gmail veut save_sent: false sinon tu le vois deux fois. Et l’expéditeur que tu passes à /send n’est plus du texte libre: identity (ou l’ancien from_address) doit se résoudre vers une identité configurée pour cette boîte, sinon l’appel revient en 422 invalid_identity, et voilà comment un agent ne peut plus envoyer au nom de qui il veut.

Un Seul Bearer, Pas De Deuxième Système D’Auth À Apprendre

L’authentification est une liste de tokens dans le fichier YAML. Liste vide, personne n’entre: depuis la v0.5.0 une liste de tokens vide ou absente échoue en fermé et tout sauf /health rend un 401, donc tu ne peux pas oublier l’auth et livrer une boîte ouverte. Sinon chaque requête à part GET /health a besoin d’un en-tête Authorization: Bearer <token> correspondant à l’un d’eux, vérifié avec hmac.compare_digest pour qu’une attaque temporelle ne puisse pas pêcher les tokens valides caractère par caractère. Plusieurs tokens veut dire que tu peux faire tourner sans interruption: tu en ajoutes un, tu déplaces tes clients dessus, tu supprimes l’ancien, personne ne remarque rien.

Depuis la v0.6.0 un token est un mapping, pas une chaîne nue: {name, token, scopes, mailboxes, dry_run}, avec six portées au choix: read, draft, organize, send, delete, admin. Donc l’agent que tu autorises à lire ton courrier n’obtient pas automatiquement le droit d’envoyer en ton nom, un token qui liste deux boîtes reçoit “not found” pour les trois autres au lieu d’un forbidden qui lui apprend qu’elles existent, et dry_run: true te rend un aperçu du message au lieu de le mettre sur le fil. Chaque écriture, et chaque écriture refusée, atterrit dans le journal d’audit sous le name de ce token, ce qui est la vraie raison pour laquelle les tokens ont gagné des noms. Une chaîne nue marche encore et accorde encore toutes les portées sur toutes les boîtes, en loguant au démarrage un avertissement qui te dit d’arrêter. Au-dessus des portées, il y a aussi un plafond d’envoi horaire glissant, compté par boîte et par token (send_guard.per_hour, 60 par défaut, surchargeable par boîte avec smtp.per_hour), qui répond 429 avec un Retry-After. Un agent coincé dans une boucle brûle son propre quota, pas ta réputation d’expéditeur.

La partie intéressante, c’est comment c’est branché. C’est un middleware ASGI brut, pas le BaseHTTPMiddleware de FastAPI, et c’est délibéré:

class _BearerASGI:
    """Pure-ASGI bearer-auth wrapper around the whole app.
    Pure ASGI (not BaseHTTPMiddleware) so it doesn't buffer streaming
    responses — required for the MCP streamable-HTTP transport mounted
    under /mcp to keep working.
    """

BaseHTTPMiddleware met le corps de la réponse en tampon pour que tu puisses l’inspecter ou le modifier, ce qui est exactement ce qui casse une connexion SSE en flux, dont le transport MCP a besoin. Donc la porte d’auth est faite à la main au niveau ASGI, vérifie les en-têtes bruts du scope avant que quoi que ce soit en aval ne commence même à répondre, et laisse la session MCP couler intacte une fois authentifiée. Même porte, même liste de tokens, même comportement 401 avec WWW-Authenticate, que tu tapes /inbox au curl ou que tu ouvres une session MCP sur /mcp. Une seule chose à configurer, une seule chose à faire tourner, une seule chose qui peut t’enfermer dehors sur une faute de frappe.

Le Serveur MCP: Un Jeu D’Outils Plat, Peu Importe Combien De Comptes Tu As

C’est la partie qui transforme “une API mail” en “un truc qu’un agent peut vraiment piloter”. /mcp expose vingt-huit outils par-dessus le transport streamable-HTTP du SDK MCP Python officiel: pas de bricolage stdio, pas de processus séparé:

  • mailboxes: la découverte: liste les comptes configurés et ce dont chacun est capable.
  • inbox: la lecture unifiée, multi-comptes, avec le même éventail que le /inbox HTTP.
  • Lecture sur un seul compte: list_folders, list_messages, search, get_message, get_raw_message, get_attachment, get_thread, list_threads, identities, mailbox_stats.
  • Organisation: mark_seen, set_labels, move_messages, create_folder, delete_message.
  • Écriture: send, reply, create_draft, list_drafts, update_draft, delete_draft, send_draft.
  • Admin: audit_log, health_check, webhook_deliveries, replay_webhook.

Vingt-huit outils. Pas vingt-huit par boîte: vingt-huit au total, pour toujours, que tu aies un compte configuré ou cent. Chaque outil qui travaille sur une boîte reçoit un argument mailbox qui accepte soit le nom de la config, soit l’adresse mail réelle, et tools/list ne rend que les outils permis par les portées du token appelant, donc un agent en lecture seule ne voit pas send du tout. Il retire même les arguments qu’il refuserait: pas de drapeau permanent sur delete_message sans la portée delete, et reply arrive avec draft figé à true pour un token qui ne peut pas envoyer. Pas de boutons morts, pas d’outils appelés send_personal et send_work et send_burner à gonfler la fenêtre de contexte du modèle à chaque appel. Un agent appelle mailboxes une fois, voit ce qui existe, et passe ensuite le nom qu’il veut en paramètre. C’est la même philosophie de design que pour le montage Telegram-en-MCP de telethon-plus: tu exposes ton propre compte comme une surface d’outils qu’un agent peut piloter, au lieu de construire un chatbot obligé de te supplier de lui coller des trucs. Même idée, autre protocole: là c’était ton compte Telegram, ici c’est ta boîte mail.

Il y a aussi un plugin passerelle stdio↔HTTP pour les runtimes d’agents du genre OpenClaw qui ne savent parler qu’à des serveurs MCP locaux en stdio: il sort via mcp-remote vers ton instance mailboxd en marche et transmet le token bearer, donc le catalogue d’outils à taille constante apparaît aussi dans des agents qui ne connaissent pas nativement le streamable-HTTP. Même jeu d’outils, même auth, juste un tuyau plus fin devant.

Le Mode Lecture, Parce Que Les Mails Marketing Sont Des Scènes De Crime En HTML

Ajoute reader=true à un fetch de message et tu obtiens un champ body_reader à côté des body_text/body_html bruts: le corps HTML passé dans html2text, images ignorées et retour à la ligne automatique coupé, styles, scripts et pixels de suivi virés, titres transformés en markdown et tableaux aplatis. La plupart des mails transactionnels et marketing arrivent en multipart/alternative, où la partie texte est un moignon inutile du genre “voir en HTML” et où le contenu réel n’existe que dans la partie HTML: le mode lecture en sort les mots sans te traîner dans la soupe de <table><tr><td style="..."> dont les gens du mail marketing semblent croire qu’on est encore en 2003. C’est additif: les corps d’origine reviennent intacts, donc une interface peut rendre le HTML pendant qu’un agent lit le markdown. La v0.6.0 a ajouté l’autre moitié: safe_html=true rend body_html_safe, le même HTML passé dans une liste blanche nh3 avec les images distantes retirées, donc une interface peut le rendre sans pinguer le serveur de tracking de l’expéditeur, plus un bloc html_analysis qui signale le texte caché à un lecteur humain et les liens qui mentent: hôtes en punycode, raccourcisseurs d’URL, IP nues, cibles javascript:, et un libellé visible dont le domaine ne correspond pas au href derrière. Ce qui, pour le mail qui débarque en prétendant venir de ta banque, est exactement la partie que tu veux qu’un agent voie.

Le Détail De Chaîne D’Approvisionnement Dont Personne D’Autre Ne S’Occupe

C’est la partie dont je veux vraiment me vanter, parce que presque personne ne le fait. pyproject.toml fixe une date exclude-newer pour uv:

[tool.uv]
exclude-newer = "2026-10-06T00:00:00Z"

Toute installation de dépendances refuse de tirer une version de paquet publiée après cet horodatage, point. Pourquoi ça compte: l’attaque moderne sur la chaîne d’approvisionnement n’est pas une opération d’État élaborée, c’est le token npm/PyPI d’un mainteneur récupéré au phishing et une version corrective malveillante poussée à trois heures du matin avant que quiconque ne le remarque. Si ton build fait juste un uv sync sur la dernière version, tu peux tirer la release empoisonnée dans les heures qui suivent sa publication. Avec un exclude-newer fixe, tout ce qui est publié après ta borne est invisible pour le résolveur: une release malveillante fraîche de trois heures du matin ne peut tout simplement pas s’installer avant que la borne ne bouge.

Le problème des bornes fixes, c’est en général qu’elles pourrissent en dette: elles marchent indéfiniment, jusqu’au moment où elles sont vieilles et où tu ne peux plus recevoir les correctifs de sécurité non plus. Donc la borne n’est pas maintenue à la main: scripts/bump_exclude_newer.sh la réécrit à minuit UTC sept jours avant le jour courant, et elle est branchée dans chaque cible make pkg-* qui change une dépendance (pkg-add, pkg-remove, pkg-update, pkg-upgrade; un simple pkg-lock ne fait que re-résoudre sur la borne qu’il a déjà), donc la porte se ré-ancre toute seule sur “il y a une semaine” automatiquement, chaque fois qu’une dépendance change réellement, ce qui veut dire que rien de moins de sept jours n’entre jamais, quel que soit le moment où tu lances ça. Tu obtiens la protection de la fenêtre de retard sans jamais avoir à te souvenir de déplacer une date à la main.

Des Tests Qui Posent Vraiment Des Octets Sur Une Socket

Le chemin SMTP n’est pas testé sur un mock qui vérifie juste qu’une fonction a été appelée: tests/test_smtp_integration.py démarre un vrai serveur SMTP, écrit à la main, sur une vraie socket TCP, en n’utilisant que socketserver de la stdlib. Il parle SMTP pour de vrai: il répond à HELO/EHLO, MAIL FROM, RCPT TO, la danse du terminateur de DATA, RSET, NOOP et QUIT, en tournant sur son propre thread d’arrière-plan. Le test pilote ensuite le vrai endpoint /send de l’appli FastAPI via un TestClient, qui appelle le vrai client smtplib, qui ouvre une vraie socket vers ce faux serveur et y envoie des octets de protocole SMTP. Le test décode ensuite ce qui a réellement atterri avec email.message_from_bytes et fait ses assertions sur les en-têtes parsés et sur l’enveloppe, y compris en confirmant que les destinataires BCC arrivent bien dans la liste RCPT TO de l’enveloppe SMTP mais ne fuitent jamais dans les en-têtes To ou Cc du corps du message, ce qui est tout l’intérêt du BCC et une chose qu’il est choquamment facile de rater dans un expéditeur mail fait à la main.

Ton Courrier Reste À Toi

Je n’essaie pas de te convaincre qu’auto-héberger ton propre proxy IMAP est ce que “les gens normaux” devraient faire. Ça ne l’est pas. Ce que je te dis, c’est que si tu es du genre à déjà faire tourner ta propre infra d’IA, et si tu as lu aigate, tu l’es probablement: il n’y a aucune raison que ton courrier soit la seule chose que tu continues à confier à un tiers. Ce conteneur donne à tes agents, tes scripts et tes pipelines curl de trois heures du matin autant de contrôle lecture/envoi/suppression sur autant de boîtes que tu en as que ce que tu choisis de donner à chaque token, sur un seul port, sans qu’un seul octet de ton courrier réel touche jamais son disque. Tue-le, redémarre-le, déplace-le sur un autre hôte: la seule chose qui vaut d’être emportée est un petit fichier SQLite de lignes d’audit et de compteurs, et ton courrier n’y a jamais été.

Prends-le sur github.com/psyb0t/docker-mailbox ou tire psyb0t/mailbox directement depuis Docker Hub. Le code source est en WTFPL, fais-en ce que tu veux, putain. L’image publiée est une autre bête: elle embarque html2text, qui est en GPL-3.0, donc l’image dans son ensemble est une œuvre combinée GPL (le THIRD_PARTY.md du dépôt a les détails). Mets-la sur ton courrier et n’y repense plus jamais.

Comment L’Installer Dans Ton Agent

Un agent qui peut lire ton courrier devrait au moins connaître l’API sans qu’on le lui dise deux fois. Tout ce qui est sous .agents/ est catalogué dans un seul marketplace, donc c’est deux commandes:

claude plugin marketplace add psyb0t/agents
claude plugin install mailbox@psyb0t

Codex utilise le même marketplace avec un verbe différent: codex plugin add mailbox@psyb0t, parce que codex plugin install n’existe pas. Il trouve aussi la skill tout seul dans un checkout du dépôt, parce qu’il scanne nativement .agents/skills/ sans que rien du tout ne soit installé. Il est également listé sur le MCP Registry officiel maintenant, donc un client qui résout ses serveurs depuis là peut le trouver sans qu’on lui tende une URL.