Ich habe fünf Mailkonten. Ein privates Gmail, das ich seit meiner Jugend mitschleppe, eine Arbeitsadresse, eine Wegwerfadresse für Anmeldungen, denen ich nicht traue, eine Adresse auf der Domain dieses Blogs, und noch eine, die ich komplett vergessen hatte, bis ich das Passwort in einer Textdatei fand. Fünf Postfächer, fünf Sätze Zugangsdaten, fünf Tabs, fünf Ungelesen-Zähler, die mich darüber belügen, wie schlecht mein Leben läuft. Jede “Unified Inbox”-App auf diesem Planeten will mir das lösen, indem sie die Obhut über alle fünf Konten übernimmt, sie in ihre Cloud synchronisiert, meine Post durch ihre “intelligente KI-Kategorisierung” schiebt und mir ein schickes UI zurückgibt, im Tausch gegen das Einzige, was ich habe und was wirklich Schutz verdient: den Inhalt meines Postfachs.
Nein. Verpiss dich. In meiner Post stecken Kontoauszüge, 2FA-Wiederherstellungscodes, medizinischer Kram, Verträge und genug persönliche Daten, um mir ein Jahrzehnt zu zerstören, wenn sie abfließen. Das gebe ich keinem risikokapitalgemästeten “Inbox Zero”-Startup, damit es in achtzehn Monaten über die SQL-Injection seines Praktikanten rausleckt. Also habe ich mailbox gebaut: ein Container, eine YAML-Datei, eine HTTP-API und ein MCP-Server auf demselben Port, null Bytes meiner Post irgendwo gespeichert außer auf den Mailservern, wo sie eh schon liegt.
Wie Jede Unified Inbox Dich Verarscht
Jedes “Unified Inbox”-Produkt folgt demselben Drehbuch, und es ist dasselbe Drehbuch, das jede SaaS-Firma auf absolut alles anwendet:
- Schritt eins: dich überzeugen, IMAP/OAuth-Zugangsdaten für jedes Konto herauszugeben, das du besitzt. Das ist das ganze Geschäftsmodell: die Aggregation ist das Produkt.
- Schritt zwei: alles in ihre Datenbank synchronisieren. Jetzt existiert eine zweite Kopie deiner gesamten Mail-Historie auf einer Infrastruktur, die du nicht kontrollierst und nicht prüfen kannst.
- Schritt drei: es “anreichern”. KI-Zusammenfassungen, intelligente Kategorisierung, “Priority Inbox”-Scores: das alles heißt, deine private Korrespondenz durch ein Modell zu schieben, das du nicht ausgesucht hast, gehostet irgendwo, wo du nicht hinsehen kannst.
- Schritt vier: alles geht in einem Leak den Bach runter. Nicht “falls”, sondern “wann”. Jeder zentralisierte Mail-Aggregator ist ein einzelnes, fettes, extrem wertvolles Ziel. Ein Leak und jedes Konto, das du angeschlossen hast, ist gleichzeitig kompromittiert, nicht verteilt über fünf Anbieter mit fünf verschiedenen Sicherheitsteams, sondern alles, auf einmal, in einem Dump, in einem Forenpost.
Und selbst wenn man das Leak-Risiko beiseitelässt, gibt es einen einfacheren Einwand: wozu braucht ein Werkzeug, das meine Post liest, eine Datenbank? IMAP ist schon die Datenbank. Der Mailserver speichert die Post bereits, indexiert sie und liefert Suchergebnisse. Eine Kopie obendrauf zu stapeln macht nichts schneller und nichts sicherer, es erschafft nur eine Sache mehr, die lecken, aus dem Takt geraten oder von einem Staatsanwalt angefordert werden kann.
Also habe ich keine Sync-Engine gebaut. Ich habe einen zustandslosen Proxy gebaut, der eine Verbindung öffnet, genau das tut, worum du gebeten hast, und auflegt.
Verbinden, Arbeit Machen, Sterben
mailbox (der Daemon heißt mailboxd) ist eine FastAPI-App, mit imaplib und smtplib aus der stdlib darunter, die so viele IMAP/SMTP-Konten, wie du ihr hinwirfst, in eine HTTP-API und einen MCP-Server auf genau demselben Port verwandelt. Der MCP-Transport ist direkt in dieselbe FastAPI-App-Instanz gemountet, als Unterroute:
app.router.routes.append(Mount(_MCP_PATH, app=mcp_manager.handle_request))
app.add_middleware(_MCPSlashNormalizer)
app.add_middleware(_BearerASGI, cfg=cfg)Derselbe Prozess, derselbe Port, dasselbe Bearer-Auth-Tor über beiden. Kein zweiter Daemon, kein zweiter Port, den du in der Firewall aufmachen musst, keine stdio-Bridge als Kindprozess, die unabhängig von der HTTP-Schicht wegsterben kann. Ein einziger uvicorn-Worker-Pool bedient sowohl REST-Anfragen als auch MCP-Streamable-HTTP-Sitzungen vom selben Socket.
Der Teil, der mich wirklich interessiert, ist das Speichermodell, und das ist: es gibt keins. Jeder IMAP-Aufruf läuft durch einen Context Manager, der eine frische Verbindung öffnet, sich einloggt, genau die angeforderte Operation macht und die Verbindung in einem finally-Block abreißt, ob es geklappt hat oder nicht:
@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 downAbsolut jede IMAP-Funktion: list_folders, list_messages, search_messages, fetch_message, fetch_raw, mark_seen: wickelt ihre Arbeit in einen frischen with _connect(cfg) as conn:-Block. Seit v0.6.0 prüft dieser Kontext die Zertifikatskette und den Hostnamen, bei ssl wie bei starttls, und es gibt nirgends eine Einstellung, die das abschaltet, also heißt ein privater Mailserver: dein CA-Bundle mounten und SSL_CERT_FILE darauf zeigen lassen, nicht mit den Schultern zucken und akzeptieren, was auftaucht. Sockets geben nach 30 Sekunden auf, statt ewig an einem Server zu hängen, der aufgehört hat zu antworten. Kein Pooling, kein Keep-Alive, kein Hintergrund-Sync-Thread, der deine Post liest, ob du es wolltest oder nicht. Es gibt jetzt zusätzlich einen optionalen Watcher, mailboxd watch, der ein IMAP IDLE offen hält und bei neuer Post an HMAC-SHA256-signierte Webhooks POSTet: nur Header und Spam-Urteile, nie Bodies. Das ist ein eigener Prozess hinter einem Compose-Profil, läuft also, weil du ihn gestartet hast. SMTP macht dasselbe, mit einem einfachen Context Manager um smtplib.SMTP_SSL oder smtplib.SMTP: öffnen, authentifizieren, senden, schließen, jedes Mal wenn /send getroffen wird.
Es existiert nirgends in diesem Code eine Nachrichtentabelle. Kein Redis, keine Sync-Engine, keine zweite Kopie deiner Post. v0.6.0 hat endlich eine SQLite-Datei eingeräumt, unter /var/lib/mailboxd/state.db, und die hält genau die sechs Dinge, die nicht auf einem IMAP-Server leben können: das Audit-Log, die stündlichen Sendezähler, die Polling-Checkpoints, die Draft-Ansprüche, die Watcher-Leases und die Webhook-Zustellwarteschlange. Sechs Tabellen, und keine davon hat eine Spalte für den Body einer Nachricht. Mounte ein Volume auf ihr Verzeichnis, sonst verlierst du beim Neustart deine Ratenlimits und die Audit-Spur, und das ist der ganze Explosionsradius. Die Post selbst wird weiterhin nie irgendwo geschrieben: töte den Container mitten im Fetch und es bleibt nichts von dir zum Wiederherstellen, weil die einzige Kopie immer auf dem Mailserver war. Das ist kein Compliance-Häkchen, das ich für den Artikel draufgeklebt habe: das ist die ganze Architektur. Null Bytes deiner Post gespeichert ist keine Marketing-Pointe, das ist, was dabei herauskommt, wenn man sich weigert, eine Sync-Engine zu schreiben.
Eine YAML-Datei, So Viele Konten Wie Du Willst
Alle fünf meiner Postfächer leben in einer einzigen Config-Datei, und jede Mailbox deklariert genau die Kombination aus imap und smtp, die sie wirklich braucht: ein Nur-Lese-Konto kann SMTP komplett auslassen, ein Nur-Sende-Relay kann IMAP auslassen:
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 validiert beim Laden alles: name muss auf [a-zA-Z0-9_-]+ passen und eindeutig sein (er wird ein URL-Segment und ist der Handle, den ein Agent als MCP-Argument mailbox weitergibt), jede Mailbox braucht mindestens eins von imap/smtp, sonst fällt das Laden der Config komplett um, und IMAP fällt standardmäßig auf 993/ssl zurück, während SMTP auf 587/starttls fällt, sofern dein Anbieter nicht seltsam ist. Jeder Textwert dort drin kann als ${SOME_VAR} geschrieben werden und wird beim Start aus der Umgebung aufgelöst, wobei eine fehlende Variable ein harter Startfehler ist und kein leeres Passwort, Zugangsdaten müssen also nie im YAML liegen. Welches Postfach du bei einem Aufruf ansprichst, wählst du über denselben name, oder, über MCP und im /inbox-Filter, über die echte Mailadresse des Kontos. Das MCP-Argument mailbox löst beides auf, ein Agent muss sich also nicht merken, ob du es vor sechs Monaten “personal” oder “gmail” genannt hast.
Die HTTP-API
Einunddreißig Routen, alle JSON rein, JSON raus, mit Fehlern in der Form {"error": {"code", "message"}, "detail": "..."}: auf das error-Objekt matchst du, und das flache detail ist noch da, damit nichts kaputtgeht, was die alte Form geparst hat. Diese zehn sind die, die du wirklich tippen wirst:
GET /health: immer offen, kein Bearer, richte deine Liveness-Probe darauf und mach weiter.GET /mailboxes: listet die Postfächer, die dein Token sieht, ihre Adressen, ihre Absenderidentitäten und welche Protokolle jedes hat.GET /inbox: das ist die, die du eigentlich willst. Sie fächert parallel über jedes Postfach mit IMAP auf, viaThreadPoolExecutor, fährt auf jedem dieselbe strukturierte Suche, führt die Ergebnisse mit den neuesten zuerst zusammen und markiert jede Nachricht mit dem Konto, aus dem sie kam. Ein totes Konto landet in einemerrors-Array, statt den ganzen Aufruf zu zerlegen: neun Postfächer am Leben und eins am Boden geben dir trotzdem die anderen neun.GET /mailboxes/{name}/folders: listet die IMAP-Ordner eines Kontos.GET /mailboxes/{name}/messages: Header mit den neuesten zuerst, mit einem rohen IMAP-search-Parameter, wenn du ihm direkt etwas wieUNSEENhinwerfen willst.GET /mailboxes/{name}/search: dieselben strukturierten Filter wie/inbox(from/to/recipient/subject/body/text/since/before, die FlagsSeen/Flagged/Answered, Größengrenzen, IMAP-Keywords,has_attachmentsundlabel), begrenzt auf ein Konto, mit Paginierung über einen opakencursorund einemsince_cursor, der nur zurückgibt, was seit deinem letzten Aufruf eingetroffen ist.GET /mailboxes/{name}/messages/{uid}: eine Nachricht, voll dekodiert, mit einem optionalenreader=true.DELETE /mailboxes/{name}/messages/{uid}: verschiebt die Nachricht in den Papierkorb, mitUID MOVEwo der Server das hat und mitCOPYplus\DeletedplusUID EXPUNGEwo nicht. Früher setzte das das Flag und hat an der Stelle expunged, was eine hervorragende Methode war, damit ein Agent dir beim ersten verpatzten Tool-Aufruf die Post endgültig löscht. Echtes Löschen istpermanent=true, und das geht nur aus dem Papierkorb heraus und nur für ein Token mit dem Scopedelete, du musst es also zweimal wollen.POST /mailboxes/{name}/messages/{uid}/seen: setzt oder entfernt dasSeen-Flag.POST /mailboxes/{name}/send: Senden über SMTP,multipart/alternative, wenn du ihm einen Text- und einen HTML-Body gibst.
Die anderen einundzwanzig kamen mit v0.6.0 und gehen mehr ums Schreiben als ums Lesen. Die rohe Quelle und einzelne Anhänge als Downloads (.../messages/{uid}/raw, .../attachments/{part_id}), Konversationssuche über Message-ID und References (.../thread, .../threads), und serverseitig abgeleitete Antworten unter .../messages/{uid}/reply, wo Empfänger, Betreff mit Re:, In-Reply-To und References alle aus dem Original gezogen werden, ein Client kann das Threading also nicht kaputtmachen, indem er sie selbst liefert. Dann die fünf Draft-Routen, und das sind die, die zählen, wenn du einen Agenten an dieses Ding lässt: er schreibt die Nachricht, lässt sie in deinem Drafts-Ordner liegen, und du entscheidest. Dazu seen und delete im Batch, Labels, Verschiebungen, Ordner anlegen, Identitäten, Ordnerstatistiken, und vier Admin-Routen: der tiefe Health-Check, das Audit-Log und die Webhook-Zustellwarteschlange mit Replay darauf.
Überall UIDs, nie Sequenznummern, die Identifikatoren bleiben also stabil, während sich das Postfach zwischen zwei Aufrufen unter dir wegbewegt. Und der Send-Endpoint ist dabei nicht faul: er setzt einen korrekten Date-Header und leitet die Message-ID-Domain aus der Absenderadresse ab, damit sie nicht dem From:-Header widerspricht, was eine klassische Spamfilter-Falle ist und die Art Detail, die man nur richtig macht, wenn man daran tatsächlich schon Post verloren hat. Früher hat er obendrein noch einen Thunderbird-User-Agent gefälscht, unter der Theorie, dass gar keiner den Heuristiken der Anbieter leicht verdächtig vorkommt; v0.6.0 hat das entfernt. Was er stattdessen jetzt macht, ist die Sendekopie ablegen: bei erfolgreichem Senden wird die Nachricht, Bcc intakt, an deinen Sent-Ordner angehängt, und wenn dieses Anhängen fehlschlägt, bekommst du warnings: ["sent_copy_failed"], kein fehlgeschlagenes Senden, das du schon getätigt hast. Gmail legt seine ausgehende Post selbst ab, ein Gmail-Postfach will also save_sent: false, sonst siehst du sie doppelt. Und der Absender, den du an /send übergibst, ist kein Freitext mehr: identity (oder das alte from_address) muss sich auf eine für dieses Postfach konfigurierte Identität auflösen, sonst kommt der Aufruf mit 422 invalid_identity zurück, und so kann ein Agent nicht länger als irgendwer senden, auf den er Lust hat.
Ein Bearer, Kein Zweites Auth-System Zu Lernen
Die Authentifizierung ist eine Liste von Tokens in der YAML-Datei. Leere Liste, niemand kommt rein: seit v0.5.0 scheitert eine leere oder fehlende Token-Liste geschlossen und alles außer /health gibt 401, du kannst die Auth also nicht vergessen und ein offenes Postfach ausliefern. Ansonsten braucht jede Anfrage außer GET /health einen Authorization: Bearer <token>-Header, der auf einen davon passt, geprüft mit hmac.compare_digest, damit ein Timing-Angriff nicht Zeichen für Zeichen nach gültigen Tokens fischen kann. Mehrere Tokens heißt, du kannst ohne Ausfall rotieren: einen neuen hinzufügen, deine Clients umziehen, den alten löschen, niemand merkt was.
Seit v0.6.0 ist ein Token ein Mapping, kein nackter String: {name, token, scopes, mailboxes, dry_run}, mit sechs Scopes zur Auswahl: read, draft, organize, send, delete, admin. Der Agent, dem du erlaubst, deine Post zu lesen, bekommt also nicht automatisch das Recht, in deinem Namen zu senden, ein Token, das zwei Postfächer listet, bekommt für die anderen drei “not found” statt eines Forbidden, das ihm beibringt, dass sie existieren, und dry_run: true gibt dir eine Vorschau der Nachricht zurück, statt sie auf die Leitung zu legen. Jeder Schreibvorgang, und jeder abgelehnte Schreibvorgang, landet im Audit-Log unter dem name dieses Tokens, was der eigentliche Grund ist, warum Tokens Namen bekommen haben. Ein nackter String funktioniert weiterhin und gewährt weiterhin jeden Scope auf jedem Postfach, und loggt beim Start eine Warnung, die dir sagt, du sollst das lassen. Über den Scopes gibt es außerdem eine gleitende Stunden-Sendeobergrenze, pro Postfach und pro Token gezählt (send_guard.per_hour, 60 als Standard, pro Postfach mit smtp.per_hour überschreibbar), die mit 429 und einem Retry-After antwortet. Ein Agent, der in einer Schleife festhängt, verbrennt sein eigenes Kontingent, nicht deine Absenderreputation.
Der interessante Teil ist, wie das eingehängt ist. Es ist eine reine ASGI-Middleware, nicht FastAPIs BaseHTTPMiddleware, und das ist Absicht:
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 puffert den Response-Body, damit du ihn inspizieren oder verändern kannst, und genau das zerstört eine streamende SSE-Verbindung, die der MCP-Transport braucht. Also ist das Auth-Tor von Hand auf ASGI-Ebene gebaut, prüft die rohen Header aus dem Scope, bevor irgendetwas weiter unten überhaupt anfängt zu antworten, und lässt die MCP-Sitzung unangetastet fließen, sobald sie authentifiziert ist. Dasselbe Tor, dieselbe Token-Liste, dasselbe 401-Verhalten mit WWW-Authenticate, egal ob du /inbox mit curl anfasst oder eine MCP-Sitzung auf /mcp aufmachst. Eine Sache zu konfigurieren, eine Sache zu rotieren, eine Sache, die dich bei einem Tippfehler aussperren kann.
Der MCP-Server: Ein Flaches Tool-Set, Egal Wie Viele Konten Du Hast
Das ist der Teil, der “eine Mail-API” in “etwas, das ein Agent wirklich fahren kann” verwandelt. /mcp stellt achtundzwanzig Tools über den Streamable-HTTP-Transport des offiziellen MCP-Python-SDK bereit: kein stdio-Gebastel, kein separater Prozess:
mailboxes: die Discovery: listet die konfigurierten Konten und was jedes kann.inbox: das vereinheitlichte Lesen über Konten hinweg, mit demselben Auffächern wie das HTTP-/inbox.- Lesen auf einem einzelnen Konto:
list_folders,list_messages,search,get_message,get_raw_message,get_attachment,get_thread,list_threads,identities,mailbox_stats. - Organisieren:
mark_seen,set_labels,move_messages,create_folder,delete_message. - Schreiben:
send,reply,create_draft,list_drafts,update_draft,delete_draft,send_draft. - Admin:
audit_log,health_check,webhook_deliveries,replay_webhook.
Achtundzwanzig Tools. Nicht achtundzwanzig pro Postfach: achtundzwanzig insgesamt, für immer, ob du ein Konto konfiguriert hast oder hundert. Jedes Tool, das auf einem Postfach arbeitet, bekommt ein mailbox-Argument, das entweder den Namen aus der Config oder die echte Mailadresse akzeptiert, und tools/list gibt nur die Tools zurück, die die Scopes des aufrufenden Tokens erlauben, ein Nur-Lese-Agent sieht send also überhaupt nicht. Es streicht sogar die Argumente, die es ablehnen würde: kein permanent-Flag an delete_message ohne den Scope delete, und reply kommt für ein Token, das nicht senden darf, mit draft fest auf true an. Keine toten Knöpfe, keine Tools namens send_personal und send_work und send_burner, die bei jedem Aufruf das Kontextfenster des Modells aufblähen. Ein Agent ruft einmal mailboxes, sieht, was existiert, und übergibt von da an den Namen, den er will, als Parameter. Es ist dieselbe Designphilosophie, die ich beim Telegram-als-MCP-Aufbau von telethon-plus benutzt habe: du legst dein eigenes Konto als Tool-Oberfläche offen, die ein Agent fahren kann, statt einen Chatbot zu bauen, der dich anbetteln muss, ihm Dinge reinzukleben. Dieselbe Idee, anderes Protokoll: dort war es dein Telegram-Konto, hier ist es dein Postfach.
Es gibt außerdem ein stdio↔HTTP-Bridge-Plugin für Agenten-Runtimes im Stil von OpenClaw, die nur mit lokalen MCP-Servern über stdio reden können: es greift über mcp-remote auf deine laufende mailboxd-Instanz zu und reicht den Bearer-Token weiter, der Tool-Katalog mit konstanter Größe erscheint also auch in Agenten, die Streamable-HTTP nicht nativ kennen. Dasselbe Tool-Set, dieselbe Auth, nur ein dünneres Rohr davor.
Der Reader-Modus, Weil Marketing-Mails HTML-Tatorte Sind
Häng reader=true an einen Message-Fetch und du bekommst ein body_reader-Feld neben dem rohen body_text/body_html: der HTML-Body durch html2text geschoben, Bilder ignoriert und Zeilenumbruch aus, Styles, Skripte und Tracking-Pixel raus, Überschriften in Markdown verwandelt und Tabellen plattgemacht. Der meiste Transaktions- und Marketing-Mailkram kommt als multipart/alternative, wo der Textteil ein nutzloser “im HTML ansehen”-Stummel ist und der eigentliche Inhalt nur im HTML-Teil existiert: der Reader-Modus holt die Wörter raus, ohne dich durch die <table><tr><td style="...">-Suppe zu schleifen, bei der die E-Mail-Marketing-Leute offenbar glauben, wir wären noch 2003. Es ist additiv: die Original-Bodies kommen unangetastet zurück, ein UI kann also das HTML rendern, während ein Agent das Markdown liest. v0.6.0 hat die andere Hälfte dazugelegt: safe_html=true gibt body_html_safe zurück, dasselbe HTML durch eine nh3-Allowlist geschoben, mit entfernten Remote-Bildern, ein UI kann es also rendern, ohne den Tracking-Server des Absenders anzupingen, plus einen html_analysis-Block, der für einen menschlichen Leser verborgenen Text markiert, und Links, die lügen: Punycode-Hosts, URL-Verkürzer, nackte IPs, javascript:-Ziele, und ein sichtbares Label, dessen Domain nicht zum href dahinter passt. Was bei der Mail, die auftaucht und behauptet, von deiner Bank zu sein, genau der Teil ist, den ein Agent sehen soll.
Das Supply-Chain-Detail, Um Das Sich Sonst Niemand Kümmert
Das ist der Teil, mit dem ich wirklich prahlen will, weil es fast niemand macht. pyproject.toml nagelt ein exclude-newer-Datum für uv fest:
[tool.uv]
exclude-newer = "2026-10-06T00:00:00Z"Jede Abhängigkeitsinstallation weigert sich, irgendeine Paketversion zu ziehen, die nach diesem Zeitstempel veröffentlicht wurde, Ende. Warum das zählt: der moderne Supply-Chain-Angriff ist keine ausgefeilte Staatsoperation, es ist das per Phishing abgegriffene npm/PyPI-Token eines Maintainers und ein bösartiges Point-Release, das um drei Uhr morgens rausgeht, bevor es jemandem auffällt. Wenn dein Build einfach uv sync auf latest macht, kannst du das vergiftete Release innerhalb von Stunden nach Veröffentlichung ziehen. Mit einem festen exclude-newer ist alles, was nach deiner Grenze veröffentlicht wurde, für den Resolver unsichtbar: ein frisches bösartiges Drei-Uhr-morgens-Release lässt sich einfach nicht installieren, bis die Grenze sich bewegt.
Das Problem mit festen Grenzen ist normalerweise, dass sie zu Schulden verfaulen: sie funktionieren unbegrenzt, bis sie alt sind und du jetzt auch keine Sicherheitspatches mehr bekommst. Also wird die Grenze nicht von Hand gepflegt: scripts/bump_exclude_newer.sh schreibt sie auf Mitternacht UTC sieben Tage vor dem aktuellen Tag um, und das hängt in jedem make pkg-*-Target, das eine Abhängigkeit verändert (pkg-add, pkg-remove, pkg-update, pkg-upgrade; ein einfaches pkg-lock löst nur auf der Grenze neu auf, die es schon hat), das Tor verankert sich also automatisch selbst neu auf “vor einer Woche”, jedes Mal wenn sich tatsächlich eine Abhängigkeit ändert, was heißt, dass nie etwas jünger als sieben Tage hereinkommt, egal wann du es laufen lässt. Du bekommst den Schutz des Verzögerungsfensters, ohne dich jemals daran erinnern zu müssen, ein Datum von Hand zu verschieben.
Tests, Die Wirklich Bytes Auf Einen Socket Legen
Der SMTP-Pfad wird nicht gegen einen Mock getestet, der nur prüft, ob eine Funktion aufgerufen wurde: tests/test_smtp_integration.py startet einen echten, handgeschriebenen SMTP-Server auf einem echten TCP-Socket, und benutzt dafür nur socketserver aus der stdlib. Er spricht echtes SMTP: er antwortet auf HELO/EHLO, MAIL FROM, RCPT TO, den Tanz mit dem DATA-Terminator, RSET, NOOP und QUIT, und läuft in seinem eigenen Hintergrund-Thread. Der Test fährt dann über einen TestClient den echten /send-Endpoint der FastAPI-App, der den echten smtplib-Client aufruft, der einen echten Socket zu diesem Fake-Server öffnet und SMTP-Protokollbytes darüber schickt. Danach dekodiert der Test mit email.message_from_bytes, was tatsächlich angekommen ist, und prüft die geparsten Header und den Envelope, inklusive der Bestätigung, dass BCC-Empfänger zwar in der RCPT TO-Liste des SMTP-Envelope landen, aber nie in die To– oder Cc-Header des Nachrichtenkörpers lecken, was der ganze Sinn von BCC ist und etwas, das in einem handgebauten Mailversand erschreckend leicht falsch läuft.
Deine Post Bleibt Deine
Ich versuche nicht, dich davon zu überzeugen, dass einen eigenen IMAP-Proxy selbst zu hosten das ist, was “normale Leute” tun sollten. Ist es nicht. Was ich dir sage, ist: wenn du der Typ bist, der schon seine eigene KI-Infrastruktur betreibt, und wenn du aigate gelesen hast, bist du es wahrscheinlich: es gibt keinen Grund, warum deine Post das Einzige sein sollte, was du weiterhin einem Dritten übergibst. Dieser Container gibt deinen Agenten, deinen Skripten und deinen Drei-Uhr-morgens-curl-Pipelines so viel Lese-/Sende-/Löschkontrolle über so viele Postfächer, wie du hast, wie du jedem Token zu geben beschließt, auf einem Port, mit null Bytes deiner echten Post, die jemals seine Platte berühren. Töte ihn, starte ihn neu, zieh ihn auf einen anderen Host: das Einzige, was mitzunehmen lohnt, ist eine kleine SQLite-Datei mit Audit-Zeilen und Zählern, und deine Post war nie darin.
Hol ihn von github.com/psyb0t/docker-mailbox oder zieh psyb0t/mailbox direkt vom Docker Hub. Der Quellcode steht unter WTFPL, mach damit, was du zum Teufel willst. Das veröffentlichte Image ist ein anderes Tier: es bündelt html2text, das GPL-3.0 ist, das Image als Ganzes ist also ein kombiniertes GPL-Werk (die THIRD_PARTY.md im Repo hat die Details). Setz es auf deine Post und denk nie wieder daran.
Wie Du Es In Deinen Agenten Einbaust
Ein Agent, der deine Post lesen kann, sollte die API wenigstens kennen, ohne dass man es ihm zweimal sagt. Alles unter .agents/ ist in einem einzigen Marketplace katalogisiert, es sind also zwei Befehle:
claude plugin marketplace add psyb0t/agents
claude plugin install mailbox@psyb0tCodex benutzt denselben Marketplace mit einem anderen Verb: codex plugin add mailbox@psyb0t, weil es codex plugin install nicht gibt. In einem Checkout des Repos findet es den Skill auch von allein, weil es .agents/skills/ nativ scannt, ohne dass überhaupt irgendwas installiert sein muss. Es steht jetzt außerdem in der offiziellen MCP Registry, ein Client, der seine Server von dort auflöst, kann es also finden, ohne dass ihm jemand eine URL hinhält.