Ik heb vijf mailaccounts. Een privé-Gmail dat ik sinds mijn jeugd meesleep, een werkadres, een wegwerpadres voor aanmeldingen die ik niet vertrouw, een adres op het domein van deze blog, en nog een dat ik compleet vergeten was tot ik het wachtwoord in een tekstbestand terugvond. Vijf postvakken, vijf sets inloggegevens, vijf tabs, vijf ongelezen-tellers die liegen over hoe slecht mijn leven ervoor staat. Elke “unified inbox”-app op deze planeet wil dat voor me oplossen door de voogdij over alle vijf accounts over te nemen, ze naar hun cloud te synchroniseren, mijn post door hun “slimme AI-categorisering” te halen en me een mooie interface terug te geven in ruil voor het enige dat ik heb en dat werkelijk bescherming verdient: de inhoud van mijn postvak.
Nee. Val dood. In mijn mail zitten bankafschriften, 2FA-herstelcodes, medische dingen, contracten, en genoeg persoonlijke data om een decennium van mijn leven te verzieken als het uitlekt. Dat geef ik niet aan een met risicokapitaal volgepompte “inbox zero”-startup zodat die het over achttien maanden via de SQL-injectie van hun stagiair laat weglopen. Dus heb ik mailbox gebouwd: één container, één YAML-bestand, een HTTP-API en een MCP-server op dezelfde poort, nul bytes van mijn post opgeslagen op een andere plek dan de mailservers waar het toch al staat.
Hoe Elke Unified Inbox Je Naait
Elk “unified inbox”-product volgt hetzelfde script, en het is hetzelfde script dat elk SaaS-bedrijf op absoluut alles toepast:
- Stap één: je overtuigen IMAP/OAuth-gegevens voor elk account dat je hebt af te staan. Dat is het hele verdienmodel: de aggregatie is het product.
- Stap twee: alles naar hun database synchroniseren. Nu bestaat er een tweede kopie van je volledige mailgeschiedenis op infrastructuur die je niet beheert en niet kunt auditen.
- Stap drie: het “verrijken”. AI-samenvattingen, slimme categorisering, “priority inbox”-scores: dat betekent allemaal je privécorrespondentie door een model halen dat je niet gekozen hebt, gehost ergens waar je niet kunt kijken.
- Stap vier: alles gaat naar de hel in een datalek. Niet “als”, maar “wanneer”. Elke gecentraliseerde mailaggregator is één vet doelwit met enorme waarde. Eén lek en elk account dat je hebt aangesloten valt tegelijk, niet verspreid over vijf providers met vijf verschillende securityteams, maar alles, in één keer, in één dump, in één forumpost.
En zelfs als je het lekrisico even wegdenkt is er een eenvoudiger bezwaar: waarom heeft een tool die mijn mail leest een database nodig? IMAP is de database al. De mailserver bewaart de post al, indexeert die en levert zoekresultaten. Daar een kopie bovenop stapelen maakt niks sneller en niks veiliger, het schept alleen nog één ding dat kan lekken, uit de pas kan lopen, of door een officier van justitie opgevraagd kan worden.
Dus heb ik geen sync-engine gebouwd. Ik heb een stateless proxy gebouwd die een verbinding opent, precies dat ene ding doet waar je om vroeg, en ophangt.
Verbinden, Het Werk Doen, Doodgaan
mailbox (de daemon heet mailboxd) is een FastAPI-app, met imaplib en smtplib uit de stdlib eronder, die zoveel IMAP/SMTP-accounts als je erin gooit verandert in één HTTP-API en één MCP-server, op precies dezelfde poort. Het MCP-transport is direct in diezelfde FastAPI-app-instantie gemount, als subroute:
app.router.routes.append(Mount(_MCP_PATH, app=mcp_manager.handle_request))
app.add_middleware(_MCPSlashNormalizer)
app.add_middleware(_BearerASGI, cfg=cfg)Hetzelfde proces, dezelfde poort, dezelfde bearer-authenticatiepoort over beide. Geen tweede daemon, geen tweede poort om in de firewall open te zetten, geen stdio-bridge die als kindproces draait en los van de HTTP-laag kan omvallen. Eén uvicorn-workerpool bedient zowel REST-verzoeken als MCP streamable-HTTP-sessies vanaf dezelfde socket.
Het stuk dat mij echt interesseert is het opslagmodel, en dat is: dat is er niet. Elke IMAP-aanroep gaat door een context manager die een verse verbinding opent, inlogt, precies de gevraagde operatie doet en de verbinding in een finally-blok afbreekt, of het gelukt is of niet:
@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 downAbsoluut elke IMAP-functie: list_folders, list_messages, search_messages, fetch_message, fetch_raw, mark_seen: wikkelt zijn werk in een vers with _connect(cfg) as conn:-blok. Sinds v0.6.0 verifieert die context de certificaatketen en de hostnaam, bij ssl net zo goed als bij starttls, en er is nergens een instelling om dat uit te zetten, dus een eigen mailserver betekent je CA-bundel mounten en SSL_CERT_FILE daarop richten, niet je schouders ophalen en aannemen wat er langskomt. Sockets geven na 30 seconden op in plaats van eindeloos te blijven hangen aan een server die gestopt is met antwoorden. Geen pooling, geen keep-alive, geen achtergrond-syncthread die je post leest of je dat gevraagd had of niet. Er is nu ook een optionele watcher, mailboxd watch, die een IMAP IDLE openhoudt en POSTt naar met HMAC-SHA256 ondertekende webhooks wanneer er nieuwe post binnenkomt: alleen headers en spamverdicten, nooit bodies. Het is een apart proces achter een Compose-profiel, dus het draait omdat jij het hebt gestart. SMTP doet hetzelfde, met een simpele context manager rond smtplib.SMTP_SSL of smtplib.SMTP: openen, authenticeren, versturen, sluiten, elke keer dat /send geraakt wordt.
Er bestaat nergens in deze code een berichtentabel. Geen Redis, geen sync-engine, geen tweede kopie van je post. v0.6.0 heeft eindelijk een SQLite-bestand toegegeven, op /var/lib/mailboxd/state.db, en dat houdt precies de zes dingen bij die niet op een IMAP-server kunnen wonen: het auditlog, de uurlijkse verzendtellers, de polling-checkpoints, de concept-claims, de watcher-leases en de webhook-bezorgwachtrij. Zes tabellen, en geen daarvan heeft een kolom voor de body van een bericht. Mount een volume op die directory, anders verlies je bij een herstart je rate limits en je auditspoor, en dat is de hele schadeomvang. De post zelf wordt nog altijd nergens weggeschreven: kill de container midden in een fetch en er blijft niets van jou over om terug te halen, want de enige kopie stond altijd al op de mailserver. Dit is geen compliance-vinkje dat ik erop geplakt heb voor het artikel: dit is de hele architectuur. Nul bytes van je post opgeslagen is geen marketingclou, dat is wat je krijgt als je weigert een sync-engine te schrijven.
Één YAML-bestand, Zoveel Accounts Als Je Wil
Al mijn vijf postvakken staan in één configbestand, en elke mailbox declareert precies de combinatie van imap en smtp die hij werkelijk nodig heeft: een alleen-lezen account kan SMTP helemaal overslaan, een alleen-verzenden relay kan IMAP overslaan:
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 valideert bij het laden alles: name moet matchen op [a-zA-Z0-9_-]+ en uniek zijn (het wordt een URL-segment en het is de handle die een agent als MCP-argument mailbox doorgeeft), elke mailbox heeft minstens één van imap/smtp nodig of het laden van de config klapt er helemaal uit, en IMAP valt standaard terug op 993/ssl terwijl SMTP terugvalt op 587/starttls, tenzij je provider vreemd is. Elke tekstwaarde daarin mag als ${SOME_VAR} geschreven worden en wordt bij het opstarten uit de omgeving opgelost, waarbij een ontbrekende variabele een harde opstartfout is en geen leeg wachtwoord, dus inloggegevens hoeven nooit in de YAML te staan. Welk postvak je per aanroep aanspreekt kies je met diezelfde name, of, op MCP en in het /inbox-filter, met het echte mailadres van het account. Het MCP-argument mailbox lost beide op, dus een agent hoeft niet te onthouden of je het zes maanden geleden “personal” of “gmail” genoemd hebt.
De HTTP-API
Eenendertig routes, allemaal JSON erin, JSON eruit, met fouten in de vorm {"error": {"code", "message"}, "detail": "..."}: op het error-object match je, en de platte detail is er nog zodat niets breekt dat de oude vorm parste. Deze tien zijn degene die je daadwerkelijk gaat typen:
GET /health: altijd open, geen bearer, richt je liveness-probe daarop en ga verder met je leven.GET /mailboxes: somt de postvakken op die je token ziet, hun adressen, hun afzenderidentiteiten en welke protocollen elk heeft.GET /inbox: dit is degene die je eigenlijk wil. Hij spreidt parallel uit over elk postvak met IMAP via eenThreadPoolExecutor, voert op elk dezelfde gestructureerde zoekopdracht uit, voegt de resultaten nieuwste-eerst samen en labelt elk bericht met het account waar het uit kwam. Een dood account landt in eenerrors-array in plaats van de hele aanroep op te blazen: negen postvakken overeind en één omver geven je nog altijd de andere negen.GET /mailboxes/{name}/folders: somt de IMAP-mappen van een account op.GET /mailboxes/{name}/messages: headers nieuwste-eerst, met een ruwe IMAP-search-parameter als je er direct iets alsUNSEENin wil gooien.GET /mailboxes/{name}/search: dezelfde gestructureerde filters als/inbox(from/to/recipient/subject/body/text/since/before, de vlaggenSeen/Flagged/Answered, groottegrenzen, IMAP-keywords,has_attachmentsenlabel), beperkt tot één account, met paginering via een opakecursoren eensince_cursordie alleen teruggeeft wat er sinds je laatste aanroep is binnengekomen.GET /mailboxes/{name}/messages/{uid}: één bericht, volledig gedecodeerd, met een optionelereader=true.DELETE /mailboxes/{name}/messages/{uid}: verplaatst het bericht naar de Trash, metUID MOVEwaar de server dat heeft en metCOPYplus\DeletedplusUID EXPUNGEwaar niet. Eerder zette dit de vlag en deed het ter plekke een expunge, wat een uitstekende manier was om een agent je post bij de eerste verklote tool-aanroep definitief te laten verwijderen. Echt verwijderen ispermanent=true, en dat werkt alleen vanuit de Trash zelf en alleen voor een token met de scopedelete, dus je moet het twee keer willen.POST /mailboxes/{name}/messages/{uid}/seen: zet deSeen-vlag of haalt hem weg.POST /mailboxes/{name}/send: versturen via SMTP,multipart/alternativeals je hem een tekst- en een HTML-body geeft.
De andere eenentwintig kwamen met v0.6.0 en gaan meer over schrijven dan over lezen. De ruwe source en losse bijlagen als downloads (.../messages/{uid}/raw, .../attachments/{part_id}), gesprekszoeken via Message-ID en References (.../thread, .../threads), en serverkant-afgeleide antwoorden op .../messages/{uid}/reply, waar de ontvangers, het onderwerp met Re:, In-Reply-To en References allemaal uit het origineel getrokken worden, dus een client kan de threading niet slopen door ze zelf mee te geven. Dan de vijf conceptroutes, en dat zijn degene die ertoe doen als je een agent in de buurt van dit ding laat: hij schrijft het bericht, laat het in je Drafts-map staan, en jij beslist. Plus seen en delete in batch, labels, verplaatsingen, mappen aanmaken, identiteiten, statistieken per map, en vier adminroutes: de diepe health, het auditlog, en de webhook-bezorgwachtrij met replay erop.
Overal UID’s, nooit sequentienummers, dus de identifiers blijven stabiel terwijl het postvak tussen twee aanroepen onder je vandaan beweegt. En het verzendendpoint is daar niet lui in: het zet een correcte Date-header en leidt het Message-ID-domein af uit het afzenderadres zodat het de From:-header niet tegenspreekt, wat een klassieke spamfilterval is en het soort detail dat je alleen goed krijgt als je er daadwerkelijk post door kwijt bent geraakt. Eerder vervalste hij daarbovenop ook nog een Thunderbird-User-Agent, op de theorie dat er géén hebben voor de heuristieken van providers licht verdacht overkomt; v0.6.0 heeft dat eruit gehaald. Wat hij nu in plaats daarvan doet is de verzonden kopie wegleggen: bij een geslaagde verzending wordt het bericht, met Bcc intact, aan je Sent-map toegevoegd, en als dat toevoegen mislukt krijg je warnings: ["sent_copy_failed"], geen mislukte verzending die je al gedaan hebt. Gmail legt zijn uitgaande post zelf weg, dus een Gmail-postvak wil save_sent: false of je ziet het dubbel. En de afzender die je aan /send meegeeft is geen vrije tekst meer: identity (of het oude from_address) moet oplossen naar een identiteit die voor dat postvak geconfigureerd is, of de aanroep komt terug met 422 invalid_identity, en zo kan een agent niet langer versturen namens wie hij maar wil.
Eén Bearer, Geen Tweede Authsysteem Om Te Leren
De authenticatie is een lijst tokens in het YAML-bestand. Lege lijst, niemand komt binnen: sinds v0.5.0 faalt een lege of ontbrekende tokenlijst dicht en geeft alles behalve /health een 401, dus je kunt de auth niet vergeten en een open postvak uitleveren. Anders heeft elk verzoek behalve GET /health een Authorization: Bearer <token>-header nodig die op een van die tokens past, gecontroleerd met hmac.compare_digest zodat een timingaanval niet teken voor teken naar geldige tokens kan hengelen. Meerdere tokens betekent dat je zonder downtime kunt roteren: je voegt een nieuwe toe, verhuist je clients ernaartoe, verwijdert de oude, niemand merkt er iets van.
Sinds v0.6.0 is een token een mapping, geen kale string: {name, token, scopes, mailboxes, dry_run}, met zes scopes om uit te kiezen: read, draft, organize, send, delete, admin. Dus de agent die je je post laat lezen krijgt niet automatisch het recht om namens jou te versturen, een token dat twee postvakken opsomt krijgt voor de andere drie “not found” in plaats van een forbidden dat hem leert dat ze bestaan, en dry_run: true geeft je een voorbeeldweergave van het bericht terug in plaats van het op de lijn te zetten. Elke schrijfactie, en elke geweigerde schrijfactie, landt in het auditlog onder de name van dat token, wat de echte reden is dat tokens namen hebben gekregen. Een kale string werkt nog en geeft nog altijd elke scope op elk postvak, en logt bij het opstarten een waarschuwing die je zegt daarmee te stoppen. Boven de scopes zit ook nog een schuivend uurplafond op verzendingen, per postvak en per token geteld (send_guard.per_hour, 60 als standaard, per postvak te overschrijven met smtp.per_hour), dat met 429 en een Retry-After antwoordt. Een agent die in een lus vastzit verbrandt zijn eigen quotum, niet jouw afzenderreputatie.
Het interessante stuk is hoe het is ingehangen. Het is pure ASGI-middleware, niet FastAPI’s BaseHTTPMiddleware, en dat is opzet:
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 buffert de responsbody zodat je die kunt inspecteren of wijzigen, en dat is precies wat een streamende SSE-verbinding sloopt, degene die het MCP-transport nodig heeft. Dus de authpoort is met de hand op ASGI-niveau gebouwd, kijkt naar de ruwe headers uit de scope voordat iets stroomafwaarts ook maar begint te antwoorden, en laat de MCP-sessie ongemoeid doorstromen zodra die geauthenticeerd is. Dezelfde poort, dezelfde tokenlijst, hetzelfde 401-gedrag met WWW-Authenticate, of je nu met curl tegen /inbox aan gaat of een MCP-sessie op /mcp opent. Eén ding om te configureren, één ding om te roteren, één ding dat je bij een typefout buiten kan sluiten.
De MCP-server: Een Platte Toolset, Hoeveel Accounts Je Ook Hebt
Dit is het stuk dat “een mail-API” verandert in “iets wat een agent echt kan besturen”. /mcp stelt achtentwintig tools beschikbaar bovenop het streamable-HTTP-transport van de officiële MCP Python-SDK: geen stdio-gefröbel, geen apart proces:
mailboxes: de discovery: somt de geconfigureerde accounts op en wat elk kan.inbox: het verenigde lezen over accounts heen, met dezelfde spreiding als de HTTP-/inbox.- Lezen op één account:
list_folders,list_messages,search,get_message,get_raw_message,get_attachment,get_thread,list_threads,identities,mailbox_stats. - Organiseren:
mark_seen,set_labels,move_messages,create_folder,delete_message. - Schrijven:
send,reply,create_draft,list_drafts,update_draft,delete_draft,send_draft. - Admin:
audit_log,health_check,webhook_deliveries,replay_webhook.
Achtentwintig tools. Niet achtentwintig per postvak: achtentwintig in totaal, voor altijd, of je één account geconfigureerd hebt of honderd. Elke tool die op een postvak werkt krijgt een mailbox-argument dat of de naam uit de config of het echte mailadres accepteert, en tools/list geeft alleen de tools terug die de scopes van het aanroepende token toestaan, dus een alleen-lezen agent ziet send helemaal niet. Het haalt zelfs de argumenten weg die het zou weigeren: geen permanent-vlag op delete_message zonder de scope delete, en reply komt voor een token dat niet mag versturen aan met draft vastgezet op true. Geen dode knoppen, geen tools die send_personal en send_work en send_burner heten en bij elke aanroep het contextvenster van het model opblazen. Een agent roept mailboxes één keer aan, ziet wat er is, en geeft vanaf dan de naam die hij wil als parameter mee. Het is dezelfde ontwerpfilosofie die ik gebruikte bij de Telegram-als-MCP-opzet van telethon-plus: je legt je eigen account bloot als een tool-oppervlak dat een agent kan besturen, in plaats van een chatbot te bouwen die je moet smeken hem dingen in te plakken. Hetzelfde idee, ander protocol: daar was het je Telegram-account, hier is het je postvak.
Er is ook een stdio↔HTTP-bridgeplugin voor agent-runtimes in de stijl van OpenClaw die alleen met lokale MCP-servers over stdio kunnen praten: die gaat via mcp-remote naar je draaiende mailboxd-instantie en geeft het bearer-token door, dus de toolcatalogus met constante grootte verschijnt ook in agents die streamable-HTTP niet native kennen. Dezelfde toolset, dezelfde auth, alleen een dunnere pijp ervoor.
De Leesmodus, Omdat Marketingmails Plaatsen Delict In HTML Zijn
Hang reader=true aan een berichtfetch en je krijgt een body_reader-veld naast de ruwe body_text/body_html: de HTML-body door html2text gehaald, met afbeeldingen genegeerd en regelafbreking uit, met styles, scripts en trackingpixels eruit, koppen naar markdown omgezet en tabellen platgeslagen. De meeste transactie- en marketingmail komt als multipart/alternative, waar het tekstdeel een nutteloze “bekijk in HTML”-stomp is en de echte inhoud alleen in het HTML-deel bestaat: de leesmodus haalt de woorden eruit zonder je door de <table><tr><td style="...">-soep te sleuren waarin de e-mailmarketingmensen blijkbaar denken dat het nog 2003 is. Het is aanvullend: de originele bodies komen ongemoeid terug, dus een interface kan de HTML renderen terwijl een agent de markdown leest. v0.6.0 heeft de andere helft toegevoegd: safe_html=true geeft body_html_safe terug, dezelfde HTML door een nh3-allowlist gehaald met remote afbeeldingen eruit, dus een interface kan het renderen zonder de trackingserver van de afzender te pingen, plus een html_analysis-blok dat tekst markeert die voor een menselijke lezer verborgen is, en links die liegen: punycode-hosts, URL-verkorters, naakte IP’s, javascript:-bestemmingen, en een zichtbaar label waarvan het domein niet overeenkomt met de href erachter. Wat bij de mail die opduikt met de bewering dat hij van je bank komt precies het stuk is dat je een agent wil laten zien.
Het Supply-chaindetail Waar Niemand Anders Zich Iets Van Aantrekt
Dit is het stuk waar ik echt over wil opscheppen, omdat bijna niemand het doet. pyproject.toml zet een exclude-newer-datum vast voor uv:
[tool.uv]
exclude-newer = "2026-10-06T00:00:00Z"Elke afhankelijkheidsinstallatie weigert welke pakketversie dan ook te trekken die na die tijdstempel is gepubliceerd, punt. Waarom dat uitmaakt: de moderne supply-chainaanval is geen uitgekiende staatsoperatie, het is het via phishing afgepakte npm/PyPI-token van een maintainer en een kwaadaardige point release die om drie uur ‘s nachts uitgaat voordat iemand het opmerkt. Als je build gewoon uv sync op latest doet, kun je de vergiftigde release binnen enkele uren na publicatie binnentrekken. Met een vaste exclude-newer is alles dat na jouw grens is gepubliceerd onzichtbaar voor de resolver: een verse kwaadaardige release van drie uur ‘s nachts kan simpelweg niet geïnstalleerd worden tot die grens opschuift.
Het probleem met vaste grenzen is normaal dat ze tot schuld verrotten: ze werken eindeloos, tot ze oud zijn en je nu ook geen securitypatches meer binnenkrijgt. Dus de grens wordt niet met de hand onderhouden: scripts/bump_exclude_newer.sh herschrijft hem naar middernacht UTC zeven dagen voor de huidige dag, en dat hangt in elk make pkg-*-target dat een afhankelijkheid verandert (pkg-add, pkg-remove, pkg-update, pkg-upgrade; een simpele pkg-lock lost alleen opnieuw op tegen de grens die hij al heeft), dus de poort ankert zichzelf automatisch opnieuw op “een week geleden”, elke keer dat er echt een afhankelijkheid verandert, wat betekent dat er nooit iets jonger dan zeven dagen binnenkomt, wanneer je het ook draait. Je krijgt de bescherming van het vertragingsvenster zonder ooit te hoeven onthouden een datum met de hand op te schuiven.
Tests Die Echt Bytes Op Een Socket Zetten
Het SMTP-pad wordt niet getest tegen een mock die alleen controleert of een functie is aangeroepen: tests/test_smtp_integration.py start een echte, met de hand geschreven SMTP-server op een echte TCP-socket, en gebruikt daarvoor alleen socketserver uit de stdlib. Hij spreekt echt SMTP: hij antwoordt op HELO/EHLO, MAIL FROM, RCPT TO, de dans met de DATA-terminator, RSET, NOOP en QUIT, en draait in zijn eigen achtergrondthread. De test bestuurt daarna via een TestClient het echte /send-endpoint van de FastAPI-app, dat de echte smtplib-client aanroept, die een echte socket naar die nep-server opent en daar SMTP-protocolbytes over verstuurt. Vervolgens decodeert de test met email.message_from_bytes wat er werkelijk is aangekomen en doet assertions op de geparste headers en op de envelope, inclusief de bevestiging dat BCC-ontvangers wel in de RCPT TO-lijst van de SMTP-envelope landen maar nooit naar de To– of Cc-headers van de berichtbody lekken, wat het hele punt van BCC is en iets dat schokkend makkelijk fout gaat in een zelfgebouwde mailverzender.
Je Post Blijft Van Jou
Ik probeer je niet te overtuigen dat je eigen IMAP-proxy zelf hosten is wat “normale mensen” zouden moeten doen. Dat is het niet. Wat ik je wel zeg: als je het type bent dat al zijn eigen AI-infrastructuur draait, en als je aigate hebt gelezen ben je dat vermoedelijk, dan is er geen enkele reden waarom je post het enige zou zijn dat je nog aan een derde partij blijft geven. Deze container geeft je agents, je scripts en je curl-pipelines van drie uur ‘s nachts zoveel lees-/verzend-/verwijdercontrole over zoveel postvakken als je hebt als je per token besluit te geven, op één poort, met nul bytes van je echte post die ooit zijn schijf aanraken. Kill hem, herstart hem, verhuis hem naar een andere host: het enige dat het waard is mee te nemen is een klein SQLite-bestand met auditregels en tellers, en je post heeft daar nooit in gezeten.
Pak hem op github.com/psyb0t/docker-mailbox of trek psyb0t/mailbox direct van Docker Hub. De source staat onder WTFPL, doe er verdomme mee wat je wil. Het gepubliceerde image is een ander beest: het bundelt html2text, dat GPL-3.0 is, dus het image als geheel is een gecombineerd GPL-werk (de THIRD_PARTY.md in de repo heeft de details). Zet hem op je post en denk er nooit meer aan.
Hoe Je Hem In Je Agent Installeert
Een agent die je post kan lezen zou de API minstens moeten kennen zonder dat je het twee keer zegt. Alles onder .agents/ staat in één marketplace gecatalogiseerd, dus het zijn twee commando’s:
claude plugin marketplace add psyb0t/agents
claude plugin install mailbox@psyb0tCodex gebruikt dezelfde marketplace met een ander werkwoord: codex plugin add mailbox@psyb0t, want codex plugin install bestaat niet. In een checkout van de repo vindt hij de skill ook zelf, omdat hij .agents/skills/ native scant zonder dat er ook maar iets geïnstalleerd is. Hij staat nu ook in de officiële MCP Registry, dus een client die zijn servers daaruit oplost kan hem vinden zonder dat iemand hem een URL aanreikt.