mailcow-agent: l'API d'AgentMail sobre el meu propi servidor de correu
L'agent familiar de casa té una bústia pròpia per gestionar factures i notificacions de l'escola. De moment, només hi podem escriure la meva dona i jo: no vull que un correu de desconeguts li coli instruccions.
És un cas concret d'una necessitat cada cop més comuna: els agents han de poder donar-se d'alta a serveis, rebre codis de verificació, llegir factures o enviar-me resums. Donar-los la meva bústia no és una opció: un agent amb accés al meu correu ho pot llegir tot, i qualsevol que m'escrigui li pot donar instruccions.
AgentMail resol això com a servei: una API per crear bústies per a agents, enviar, rebre i reaccionar amb webhooks. Està ben pensat, però les bústies viuen a la infraestructura d'un tercer. Jo ja tinc un servidor de correu, un mailcow, i la mateixa regla que a pdrop: res surt a internet si no ho decideixo jo. Així va néixer mailcow-agent.
En resum
- L'API és la d'AgentMail, copiada: els seus SDK, la CLI i el servidor MCP funcionen només canviant-los l'adreça.
- L'especificació d'AgentMail va fer de revisor, i va trobar errors que els tests de l'agent no veien.
- Cada agent té la seva clau, i només veu les bústies que ha creat ell. A la meva xarxa, fins i tot se la pot fer sol.
Per què copiar una API
El primer impuls era fer una API pròpia, petita i a la meva mida. Hauria estat un error. Una API pròpia vol dir escriure els clients, la documentació i el servidor MCP, i que cap agent la conegui.
Copiar la d'AgentMail ho dona tot fet. El SDK de Python, el de TypeScript, el de Go, la CLI i el servidor MCP són oberts i accepten una URL base. Si el meu servei respon igual que AgentMail, tot això funciona sense tocar res:
client = AgentMail(
api_key="…",
environment=AgentMailEnvironment(http="https://mailcow-agent.<tailnet>.ts.net", websockets=""),
)
inbox = client.inboxes.create(request=CreateInboxRequest(username="sales-bot"))
I l'avantatge més gran no era el codi estalviat: AgentMail publica l'especificació sencera, l'OpenAPI de l'API i l'AsyncAPI dels WebSockets. No calia decidir com havia de ser cada resposta. Ja estava escrit.
Com és per dins
mailcow-agent és un sol procés de Python i un fitxer SQLite, al costat de mailcow. El correu de debò el continua fent mailcow: MX, filtre de correu brossa, DKIM, cues i TLS. El servei només hi afegeix la capa que parla amb els agents.
Crear una bústia és crear un compte de mailcow amb la seva API, i la puc obrir des del webmail com qualsevol altra. Rebre és IMAP: un fil per bústia escolta amb IDLE i emet message.received. Enviar és SMTP com la mateixa bústia. Els esdeveniments surten per webhooks signats com els d'AgentMail o per WebSocket, que és el que va millor a casa: l'agent obre la connexió i no cal cap URL pública. No hi ha Redis, ni Postgres, ni cap cua externa. Per a les bústies d'uns quants agents, SQLite en té de sobres.
El servei només escolta a la meva tailnet, la xarxa privada de Tailscale on ja viuen els meus agents.
L'especificació com a revisor
Ara els agents escriuen el codi i també els tests, i ja vaig explicar per què la cobertura no és verificació. Aquí tenia una cosa millor que els tests de l'autor: un contracte escrit per algú altre.
Cada resposta dels tests es valida contra l'OpenAPI: codi d'estat, tipus de contingut i esquema JSON. Si un camp no hi és o té el tipus que no toca, el test falla, encara que el test no ho miri. Un altre test recorre les 149 operacions de l'especificació, i cadascuna o està implementada o és en una llista d'excepcions amb el motiu. N'hi ha 16; AgentID n'és un exemple, perquè depèn dels serveis d'AgentMail i no té sentit en un servidor propi.
Els tests de l'agent passaven tots. Llavors hi vam posar Schemathesis, que genera peticions a partir de l'especificació i les llança contra cada operació. A la primera passada va trobar una adreça amb un salt de línia que injectava capçaleres i tombava el servei amb un error 500. També caràcters de control que trencaven la cerca, i dos client_id buits que es trepitjaven. Cap test els havia buscat, perquè cap test sabia que calia.
Com ha anat
Tot el projecte va sortir d'una sola sessió de Claude Code al núvol, amb la manera de treballar que explicava a com programo ara. En poques hores: 19 commits, unes 7.300 línies de codi i 3.500 de tests.
Primer vam aixecar el nucli —l'API i la compatibilitat amb MCP— i el vam passar per dues revisions, de seguretat i d'arquitectura. La de seguretat va partir d'una premissa concreta: qualsevol pot escriure a una bústia. Va trobar, entre altres coses, que un correu malformat podia aturar la sincronització i que es confiava en el From, una capçalera falsificable. Vam corregir els problemes i afegir un test de regressió per a cada un.
Després vaig fer un pla per a les peces que faltaven, amb els tests definits abans d'implementar-les. El vam repartir en quatre branques paral·leles: rebots, text dels adjunts, desplegament i claus per agent. En uns vint minuts vam integrar-les totes, tot i haver de resoldre conflictes en tres fitxers.
La decisió que més m'agrada del pla és una que diu que no. AgentMail ofereix accés per IMAP i SMTP, i el pla ho tenia dissenyat. Ho vaig ajornar: els meus agents fan servir el SDK o l'MCP, i si vull llegir la bústia d'un agent ja la tinc al webmail. Ho faré el dia que una eina que només parli IMAP ho necessiti.
Una clau per agent
Aquí és on m'he separat d'AgentMail. A AgentMail, una clau d'organització pot crear bústies, però també les veu totes. Si li dono una d'aquestes claus a un agent perquè es creï la seva bústia, pot llegir el correu de tots els altres.
mailcow-agent hi afegeix les claus de provisió: poden crear bústies i claus, però només veuen les bústies que han creat elles o les seves claus filles. Per a les altres, la resposta és un 404. Cada agent té la seva clau a l'entorn i, en arrencar, demana la bústia amb un client_id fix. Cada execució obté la mateixa bústia, i mai la d'un altre agent.
Per explicar-ho als agents, cada instància serveix un /skill.md, com fa AgentMail, amb l'adreça del servidor ja posada: com autenticar-se, com obtenir la bústia, exemples amb els SDK i l'MCP, i unes regles de seguretat. N'hi ha prou amb donar a l'agent l'enllaç i una clau.
I si no vull que l'agent enviï res pel seu compte, una clau amb draft_create i sense draft_send ni message_send només pot deixar esborranys. És la manera més senzilla de posar una persona entre l'agent i el món.
Que s'apuntin sols
Tot i això, encara m'havia de posar jo pel mig: crear una clau per a cada agent abans que pogués fer res. Quan ho vaig provar, no tenia gaire sentit. Són agents meus, a la meva xarxa, i soc jo qui els diu que es creïn una bústia quan necessitin enviar un correu.
AgentMail ja ho preveu: té una alta per a agents, POST /v0/agent/sign-up, que no demana cap clau. L'agent tria un nom i rep una clau i una bústia. Per evitar abusos, si hi ha una persona vinculada, l'agent només li pot escriure fins que ella el verifica amb un codi rebut per correu; sense cap persona vinculada, no pot enviar. A casa no em cal aquest pas: l'accés a la tailnet ja limita quins agents poden arribar al servei.
Així que mailcow-agent implementa la mateixa ruta, sense el codi. Està apagada per defecte, perquè si un dia el servei sortís a internet, qualsevol podria crear bústies al meu domini. L'agent rep una clau de provisió amb un límit de tres bústies, i el /skill.md li explica com fer-ho. Ara n'hi ha prou amb dir-li a un agent «llegeix el /skill.md i fes-te una bústia».
Per no perdre el fil de qui s'ha apuntat, hi ha una pàgina /admin que només obro jo. No cal ni la clau d'administrador: tailscale serve diu al servei qui fa la petició, i si soc jo, entro directament. Hi veig les bústies i les claus, qui ha creat cada una i quan s'han fet servir per última vegada, i en puc revocar una amb un clic. El correu de cada bústia no hi és: per llegir-lo, mailcow ja deixa a l'administrador entrar al webmail com si fos l'agent.

Com el faig servir
Ara ja ho fan servir dos agents: el familiar, amb aquesta restricció de remitents, i el de genealogia, que també té la seva pròpia bústia.
Aquesta API va sortir en poques hores, i no perquè l'agent sigui ràpid, sinó perquè algú altre ja havia escrit el contracte i els tests el feien complir. Ara cada agent té la seva bústia al meu servidor, i cap no pot llegir la dels altres. La pròxima vegada que un agent necessiti una cosa nova, primer buscaré si algú ja n'ha escrit l'especificació.