Local mail client with a POP3/SMTP core and an HTTP API, so an AI can search and send your mail
Find a file
Thijs 6efd8c7de5
All checks were successful
release / build and publish aimail.exe (push) Successful in 5m32s
build / test and build (push) Successful in 5m46s
Release: genereer de documentatie met go run, niet met het amd64-artefact
Elke tweede release mislukte, en dat lag niet aan de release-stap. In de pool
zitten runners van meer dan één architectuur: de mislukte runs duurden 190-210
seconden, de geslaagde 90-95. Op de trage — een ARM-machine — viel de stap om
die de zojuist gebouwde linux-amd64 binary uitvoert om AI.md en openapi.yaml te
schrijven. Een artefact dat je cross-compileert kun je niet zomaar draaien.

`go run` compileert voor de machine waar de job toevallig op landt, dus dat is
weg. De versie wordt nog steeds ingestempeld, dus de spec bij een release blijft
het versienummer van die release noemen.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShjhDbk1XUdJ3pMicrvRCf
2026-08-01 16:15:29 +02:00
.forgejo/workflows Release: genereer de documentatie met go run, niet met het amd64-artefact 2026-08-01 16:15:29 +02:00
cmd/aimail Lezen vanaf de opdrachtregel: list, show en attach 2026-08-01 16:02:51 +02:00
internal Lezen vanaf de opdrachtregel: list, show en attach 2026-08-01 16:02:51 +02:00
.gitignore Fix: cmd/aimail ontbrak in de repo door een te breed gitignore-patroon 2026-07-31 19:50:07 +02:00
go.mod IMAP in plaats van POP3: alle mappen, niet alleen de inbox 2026-07-31 20:10:07 +02:00
go.sum IMAP in plaats van POP3: alle mappen, niet alleen de inbox 2026-07-31 20:10:07 +02:00
README.md Lezen vanaf de opdrachtregel: list, show en attach 2026-08-01 16:02:51 +02:00

aimail

Je mail op je eigen machine, doorzoekbaar door een AI.

Haalt je mailbox binnen via IMAP — álle mappen, dus ook Verzonden en je archief — zet alles in één SQLite-bestand met een full-text index, en biedt dat aan via een lokale HTTP-API — zodat Claude (of wat je ook gebruikt) door je mail kan spitten en er namens jou eentje kan versturen.

Eén aimail.exe, geen installatie, geen runtime, geen cloud.

Snel starten

aimail sync      # haal je mail binnen
aimail serve     # zet de API aan

Alles staat in de map waar aimail.exe staat: de config, de database, klaar. Zet hem dus ergens waar dat mag — D:\aimail\ bijvoorbeeld, niet in Program Files. Kun je daar niet schrijven, dan wijkt hij uit naar %APPDATA%\aimail.

aimail init            # een config om in te vullen
aimail init --proton   # idem, maar met de instellingen van Proton Bridge erin
aimail init --print    # naar het scherm, om zelf ergens neer te zetten
{
  "accounts": [{
    "name": "main",
    "imap": { "host": "imap.jouwprovider.nl", "port": 993, "tls": true,
              "username": "jij@domein.nl", "password": "…" },
    "smtp": { "host": "smtp.jouwprovider.nl", "port": 465, "tls": true,
              "username": "jij@domein.nl", "password": "…",
              "from": "jij@domein.nl", "from_name": "Jouw Naam" }
  }],
  "api": { "addr": "127.0.0.1:8765", "token": "…automatisch gegenereerd…" },

  // Optioneel. Zonder deze regel komt mail.db naast de config te staan.
  // Schuine strepen werken ook op Windows, dus geen dubbele backslashes.
  "database": "D:/aimail/mail.db"
}

Proton Mail (via Bridge)

Bridge draait op je eigen machine en biedt daar IMAP en SMTP aan met STARTTLS en een zelfondertekend certificaat:

"imap": { "host": "127.0.0.1", "port": 1143, "tls": false, "insecure": true,
          "username": "jij@proton.me", "password": "<wachtwoord uit Bridge>" },
"smtp": { "host": "127.0.0.1", "port": 1025, "tls": false, "insecure": true,
          "username": "jij@proton.me", "password": "<wachtwoord uit Bridge>",
          "from": "jij@proton.me" }

"tls": false betekent hier STARTTLS, niet onversleuteld — dat is wat Bridge aanbiedt. "insecure": true is nodig omdat Bridge zijn eigen certificaat tekent; dat kan hier, want het verkeer komt niet van je machine af. Het wachtwoord is dat uit het Bridge-venster, niet je Proton-wachtwoord. Staan 1143 en 1025 al vol, dan kiest Bridge andere poorten en laat hij ze zien.

Commando's

aimail sync [--max N] [--folder F] nieuwe mail ophalen. Kan zonder zorgen herhaald worden
aimail serve de lokale API draaien
aimail search "factuur" [--since 2025-07-01] [--until 2025-07-31] zoeken vanaf de opdrachtregel
aimail list [--limit N] [--folder F] de nieuwste berichten
aimail show ID [--raw] één bericht: koppen, tekst, bijlagenlijst
aimail attach ID [--out DIR] de bijlagen opsommen of wegschrijven
aimail send --to a@b.nl --subject S --body B mail versturen
aimail status hoeveel mail er staat, waar, en hoe ver een lopende sync is
aimail init [--proton] een voorbeeldconfig neerzetten
aimail docs [--api] de handleiding voor een AI, of de API-specificatie
aimail measure wat er op de server staat, naast wat je gearchiveerd hebt

Lezen vanaf de opdrachtregel

Alles wat de API kan, kan ook zonder de API:

aimail search "contract" --limit 5     # het nummer opzoeken
aimail show 4821                       # koppen, tekst, wat eraan hangt
aimail show 4821 --raw > bericht.eml   # het origineel, byte voor byte
aimail attach 4821                     # wat zit eraan?
aimail attach 4821 --out .             # zet het op schijf
aimail attach 4821 --index 2 --out D:\facturen

De bijlagen staan niet apart opgeslagen: ze zitten in het originele bericht, dat heel bewaard blijft. Ze worden er dus uitgehaald zoals ze binnenkwamen, en niets staat dubbel.

Bij het wegschrijven wordt de bestandsnaam ontdaan van zijn scherpe randjes. Die naam komt namelijk uit de mail, en dus van de afzender: ..\..\Opstarten\ kwaad.exe is een naam die een bericht gewoon mag dragen. Alleen het laatste stuk blijft over, rare tekens gaan eruit, en een bestand dat er al stond wordt nooit overschreven — er komt een -1 achter.

Alles als JSON

Achter elk commando mag --json:

aimail status --json
aimail search "factuur" --limit 5 --json
aimail sync --json
aimail measure --json

Dan komt het antwoord als één JSON-document in plaats van als tekst voor een mens — en dat geldt ook voor fouten, die als {"error": "…"} op stderr komen met exitcode 1. Zo hoeft een script nooit halverwege over te schakelen op het lezen van proza. Bij sync --json blijft de voortgangsregel weg (die zou dwars door het document heen lopen); volg een lopende sync dan met aimail status --json in een ander venster.

De API

Alles behalve /health wil het token uit je config, als Authorization: Bearer <token> of als ?token=<token>.

GET / documenteert zichzelf — handig als je een AI erop loslaat
GET /stats hoeveel mail, over welke periode, en de voortgang van een lopende sync
GET /search?q=&limit=&offset=&since=&until=&folder= full-text zoeken, beste match eerst
GET /messages?limit=&offset= nieuwste berichten
GET /messages/{id} één bericht met volledige tekst en bijlagenlijst
GET /messages/{id}/raw het originele bericht
POST /send versturen
POST /sync nu ophalen. {"background":true} komt meteen terug en laat hem doorlopen
GET /ai.md hoe een AI hiermee om moet gaan (geen token nodig)
GET /openapi.yaml de volledige API-specificatie (geen token nodig)
curl -s "http://127.0.0.1:8765/search?q=factuur&token=$TOKEN"
curl -s "http://127.0.0.1:8765/send?token=$TOKEN" \
  -d '{"to":["jan@example.nl"],"subject":"Hoi","body":"Bericht"}'

Zoeken

De zoekopdracht is FTS5-syntax, dus meer dan losse woorden:

factuur                        losse term
"betalen voor 1 augustus"      exacte zin
factuur AND juli               beide
factuur NOT herinnering        de een zonder de ander
reserv*                        begint met
subject:factuur                alleen in het onderwerp
from_addr:leverancier.nl       alleen van die afzender
folder:Verzonden               alleen in die map

Punten en apenstaartjes zijn in FTS5 eigenlijk syntax; die worden automatisch netjes aangehaald, zodat from_addr:bank.nl gewoon werkt. Accenten tellen niet mee: reintegratie vindt ook reïntegratie.

Datums zijn geen tekst. In een mail uit juli 2025 staat het woord "juli 2025" niet, dus daar is een aparte parameter voor: --since 2025-07-01 --until 2025-07-31, of since=/until= op de API.

Klopt mijn archief?

aimail measure

Vraagt de server hoe groot elk bericht is — zonder er ook maar één te downloaden, want die maat staat al in zijn index — en zet dat naast wat jij hebt:

                               ── on the server ──    ──── archived ────
folder                         messages       size   messages       size
INBOX                             17090     1.2 GB      17090     1.2 GB
Sent                                901     180 MB        901     180 MB
Trash                              8412     410 MB          0        0 B  (spam/trash, not archived)
All Mail                          67915     3.6 GB          0        0 B  (a second view of mail you already have)

Handig als je provider een heel ander getal noemt dan jij: klopt de kolom "server" met wat je verwacht, dan zit het verschil niet in je mail. (Bij Proton is de opslag één pot voor Mail, Drive, Pass en Calendar — dus meestal staan die gigabytes in Drive, en soms in de prullenbak dáárvan.)

Documentatie voor de AI

Naast deze README zitten er twee documenten ín het programma:

aimail docs            # de handleiding zoals een AI hem moet lezen
aimail docs --api      # de OpenAPI-specificatie
aimail docs --out .    # allebei als bestand

Dezelfde twee hangen aan elke release, en de draaiende API serveert ze op /ai.md en /openapi.yaml — zonder token, want moeten weten hoe je een token meegeeft voordat je mag lezen hoe je een token meegeeft is een raadsel. Wijs een AI dus gewoon op http://127.0.0.1:8765/ai.md en hij weet genoeg.

Een grote mailbox

Een mailbox van 25 GB wordt een database van ongeveer 28 GB: de originele berichten worden bewaard zoals ze zijn (bijlagen incluis), en daar komt de tekst plus de zoekindex bovenop. Zet "database" dus op een schijf waar dat past.

De eerste sync duurt uren, geen minuten — en de rem zit meestal niet bij aimail maar bij je mailserver. Dat is niet erg, want:

  • Je kunt hem afbreken. Ctrl-C, afmelden, computer uit; wat binnen is blijft binnen en de volgende aimail sync gaat verder waar hij gebleven was. Een bericht wordt herkend aan map + UIDVALIDITY + UID, niet aan volgnummer.
  • Je kunt hem in stukken doen met aimail sync --max 5000.
  • Je ziet waar hij is: één regel die zichzelf bijwerkt, met aantal, omvang, snelheid en een schatting van de resterende tijd.
  • Ook vanuit een ander venster. De sync schrijft zijn voortgang elke paar seconden in de database, dus aimail status elders vertelt hoe ver hij is — en GET /stats doet hetzelfde voor een AI. Ligt hij eruit zonder netjes af te sluiten, dan zegt status dat ook in plaats van eeuwig "bezig" te melden.
$ aimail status
database  D:/aimail/mail.db  (12.4 GB)
messages  84.213
sync      running: Archive, 84213 of ~176400 messages, 12.4 GB, nog ~2h14m

Hoe het in elkaar zit

cmd/aimail          de opdrachtregel
internal/docs       de handleiding en de API-spec, in het binary ingebakken
internal/imapclient IMAP: mappen opsommen en berichten ophalen
internal/mailmsg    ontleden: gecodeerde koppen, tekensets, meerdelige berichten
internal/store      SQLite + FTS5
internal/sync       ophalen -> ontleden -> opslaan
internal/mailsend   SMTP, inclusief het opstellen van het bericht
internal/api        de lokale HTTP-API

Alles staat in één bestand (standaard %APPDATA%\aimail\mail.db, of waar "database" naar wijst). Kopieer dat en je hebt je hele mailarchief mee. De originele bytes zitten in een eigen tabel, niet in een kolom van de berichten: bij tientallen gigabytes scheelt dat het verschil tussen een lijst die direct verschijnt en een die eerst langs al je bijlagen moet. Het originele bericht wordt altijd bewaard, dus als het ontleden ooit beter wordt, kan alles opnieuw geïndexeerd worden zonder nieuwe download.

Dingen die je moet weten

Hij loopt al je mappen langs, behalve spam en prullenbak — daar wil je geen AI antwoorden uit halen — en behalve doorkijkjes: mappen die post laten zien die al ergens anders staat. Proton Bridge en Gmail bieden namelijk elk label én een "All Mail" aan als gewone IMAP-map, dus een bericht in je inbox met twee labels staat er vier keer in, met vier verschillende UID's. Die worden overgeslagen, want een bericht staat in precies één échte map.

Wil je het zelf bepalen, zet dan "folders": ["INBOX", "Sent", "Labels/Werk"] in het imap-blok, of draai aimail sync --folder Verzonden. Wat je bij naam noemt krijg je, ook een label — dan heb je erom gevraagd.

Er wordt niets op de server veranderd. Mappen gaan alleen-lezen open en de berichten worden met PEEK opgehaald, dus je ongelezen mail blijft ongelezen en er wordt nooit iets verwijderd.

Een tweede sync haalt niets opnieuw op. Een bericht wordt herkend aan map + UIDVALIDITY + UID; dat laatste getal is precies zolang geldig als de server zijn mailbox niet hernummert, en daarom horen die drie bij elkaar. Hernoem je een map in je mailprogramma, dan ziet aimail hem als nieuw en haalt hij hem opnieuw op.

Het wachtwoord staat leesbaar in de config. Dat is een bewuste keuze: het alternatief op Windows koppelt het bestand aan één gebruikersprofiel, waardoor "even naar mijn nieuwe laptop kopiëren" niet meer werkt. Het bestand krijgt wel rechten waarbij alleen jij erbij kunt.

De API luistert op localhost en wil een token. Hij kan mail versturen namens jou; dat hoort niet open op het netwerk te staan. Zet je hem toch op een ander adres, dan waarschuwt hij bij het starten.

Zelf bouwen

go test ./...                                    # alles
GOOS=windows GOARCH=amd64 go build ./cmd/aimail  # aimail.exe

Pure Go, ook de SQLite-driver — daarom cross-compileert het vanaf elke machine naar één .exe zonder afhankelijkheden.