Warum wir nicht Postfix genommen haben
Wenn eine Mail an eine Mailiy-Adresse ankommt, läuft sie durch eine bewusst schmale Pipeline. Statt Postfix nutzen wir das Node-Paket smtp-server. Es nimmt Verbindungen an, prüft im onRcptTo-Hook gegen die Postgres-Mailbox-Tabelle und lehnt unbekannte oder abgelaufene Adressen sofort mit 550 ab. Niemand soll Bandbreite dafür bezahlen, Mails entgegenzunehmen, die wir ohnehin nicht zustellen können.
Postfix hätte uns kampferprobtere Queueing-Semantik gegeben, aber zum Preis eines zweiten Prozesses, einer eigenen Config-Sprache und eines Maildirs, das wir selbst hätten garbage-collecten müssen. Für einen Dienst, dessen ganzes Versprechen lautet „die Mailbox stirbt in 60 Minuten", sind das viele bewegliche Teile, die mit einer Zeile in Postgres synchron bleiben müssten.
Annehmen, queuen, abarbeiten
Akzeptierte Mails werden im onData-Stream gegen die Größen- und Speicherlimits des Empfänger-Tiers geprüft:
onData(stream, session, callback) {
const tier = session.mailbox.tier
const maxBytes = SIZE_LIMITS[tier] // 5 MB anon, 10 MB free, bis 50 MB Pro
// ... in einen längenbegrenzten Buffer streamen
}
Sobald wir einen vollständigen RFC-822-String haben, wandert er in eine BullMQ-Queue namens incoming-mail. Der Worker holt ihn raus, parst mit mailparser, schreibt die Message und ihre Attachments-Zeilen nach Postgres und feuert pro registriertem Webhook ein message.received-Event in eine zweite Queue (webhook-dispatch).
Dass beide Stufen über Queues laufen und nicht synchron im SMTP-Handler, ist Absicht: Wenn der Worker für 30 Sekunden steht, akzeptiert SMTP trotzdem weiter — die Mails liegen in Redis und werden nachträglich abgearbeitet. Das ist deutlich freundlicher zu Absendern als ein 4xx-Tempfail im falschen Moment, und es bedeutet, dass wir nicht-triviale Verarbeitung (Virenscan, Attachment-Indexierung) machen können, ohne dass der Absender je warten muss.
Die Lösch-Seite ist nur eine Schleife
Die Ablauf-Seite ist eine schlichte Schleife: Ein periodischer expiry-sweep-Job läuft alle 60 Sekunden und löscht alle Mailboxen, deren expiresAt vergangen ist.
const expired = await prisma.mailbox.findMany({
where: { expiresAt: { lt: new Date() } },
select: { id: true },
})
await prisma.mailbox.deleteMany({ where: { id: { in: expired.map((m) => m.id) } } })
Prisma cascadet die Löschung in derselben Transaktion auf Messages und Attachments. Es gibt keine zweite Kopie irgendwo — kein Backup, kein S3-Lifecycle, kein warmes „Archiv". Wenn der Sweep durchgelaufen ist, ist die Adresse weg, und damit auch alles, was je an sie geschickt wurde.
Wir haben über ein Soft-Delete mit Tombstone für forensische Zwecke nachgedacht. Wir haben uns dagegen entschieden. Das ganze Produkt-Versprechen ist „nach Ablauf gibt es nichts wiederherzustellen" — und das kann man nicht ehrlich behaupten, wenn irgendwo eine Zeile mit gesetztem deletedAt und unangetastetem Body liegt.
Was das für die REST-API bedeutet
Weil dieselbe Postgres-Instanz von der API gelesen und vom SMTP-Ingest geschrieben wird, ist die Verzögerung zwischen einer eingehenden Mail und GET /v1/mailbox/:id/messages sub-sekundär. Für Playwright- und Cypress-Test-Flows ist das der Unterschied zwischen einer Ein-Sekunden-Poll-Schleife und einem flakigen Test.
Was schiefgehen kann
Zwei reale Fehlermodi, beide sichtbar im Design:
- Redis-Verlust — wenn Redis zwischen SMTP-Annahme und Worker-Schreiben verschwindet, ist diese Mail weg. Wir nutzen Redis-Persistenz (AOF), um das selten zu machen, aber unmöglich ist es nicht.
- Clock-Skew auf dem Sweep-Knoten — wenn die Uhr des Sweepers vor der von Postgres ist, können Mailboxen um diesen Drift zu früh gelöscht werden. NTP hält das in Millisekunden-Bereichen, aber es ist erwähnenswert.
Beides ist vor dem Nutzer nicht versteckt. Eine Mail, die nicht in der Inbox landet, ist die gleiche beobachtbare Folge wie eine abgelaufene 60-Minuten-Adresse — du generierst eine neue und machst weiter. Das ganze Produkt ist um diese Tatsache herum gebaut.
Jetzt ausprobieren → Wegwerf-Adresse auf der mailiy-Startseite generieren — ein Klick, in unter zwei Sekunden bereit, keine Anmeldung, kein Account.