diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8e2e0d5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,321 @@ +# AGENTS.md + +Diese Datei ist die operative Kurzreferenz für Codex-Instanzen und andere +Automatisierungen, die an diesem Projekt arbeiten. Die produktive Installation +läuft derzeit unter `/opt/inbound-email` auf einem Linux-Server. + +## Zweck und Architektur + +Das Projekt nimmt E-Mails über SMTP an, parst sie und sendet den geparsten Inhalt +per HTTP POST an `WEBHOOK_URL`. + +Der Ablauf in `server.js` ist: + +1. `smtp-server` nimmt SMTP-Verbindungen auf dem konfigurierten Port an. +2. `services/emailParser.js` verarbeitet die Nachricht mit `mailparser`. +3. Anhänge werden über `services/s3Service.js` nach S3 hochgeladen, sofern sie + nicht größer als `MAX_FILE_SIZE` sind. +4. Die geparste Nachricht wird vor der SMTP-Bestätigung als JSON im persistenten + Spool gespeichert. +5. `better-queue` sendet die Spool-Datei an den Webhook. +6. Die Spool-Datei wird erst nach erfolgreicher Webhook-Antwort gelöscht. + +Die Zustellung ist damit grundsätzlich „at least once“: Bei einem Absturz nach +erfolgreicher Webhook-Annahme, aber vor dem Löschen der Spool-Datei, kann eine +Nachricht doppelt zugestellt werden. Der Webhook sollte deshalb idempotent sein. + +Die Verzeichnisse haben folgende Bedeutung: + +| Pfad | Zweck | +| --- | --- | +| `SPOOL_DIR/pending` | Noch nicht erfolgreich zugestellte Nachrichten | +| `SPOOL_DIR/failed` | Unlesbare oder ungültige Spool-Dateien zur manuellen Prüfung | +| `LOG_DIR` | JSON-Anwendungslogs mit täglicher Rotation | +| `/opt/inbound-email/logs` | Historische Logs der alten Installation; nicht als aktueller Spool verwenden | + +Der Spool wird beim Start und danach ungefähr jede Minute gescannt. Fehlende +Webhook-Erreichbarkeit darf daher nicht durch manuelles Löschen von Dateien +„behoben“ werden. Zuerst Fehlerursache und Payload prüfen. + +## Wichtige Betriebsregeln + +- Es darf nur eine Instanz auf dem SMTP-Port laufen. Niemals parallel + `npm start`, PM2, eine zweite Supervisor-Definition oder eine separate + systemd-App-Unit starten. +- Produktionsseitig ist Supervisor der Prozessmanager für `inbound-email`. + systemd verwaltet den Supervisor selbst, nicht zusätzlich dieselbe Node-App. +- Der produktive Start verwendet absichtlich den festen Pfad + `/usr/bin/node /opt/inbound-email/server.js`, nicht `npm start` und nicht eine + NVM-Version. Dadurch ist der Start unabhängig von einer interaktiven Shell. +- Supervisor wird nicht mit `systemctl restart supervisor` neu gestartet, wenn + lediglich `inbound-email` geändert wurde; dadurch würden auch die Laravel- + Worker beeinflusst. Für die App nur `inbound-email` neu laden bzw. starten. +- Vor Änderungen an Laufzeit, Spool oder Logs immer den aktuellen Prozess und + Port prüfen. Ein verwaister Altprozess kann sonst den Port blockieren. +- Keine Spool-Dateien, `.env`-Dateien, Logs oder TLS-Schlüssel committen. + +## Konfiguration + +Die Konfiguration wird aus `.env` geladen. `WEBHOOK_URL` ist zwingend und hat +absichtlich keinen produktiven Defaultwert. Ungültige Werte führen beim Start zu +einem kontrollierten Fehler, damit Supervisor den Fehler erkennen kann. + +| Variable | Bedeutung | Standardwert | +| --- | --- | --- | +| `WEBHOOK_URL` | Ziel-URL für HTTP POST | erforderlich | +| `PORT` | SMTP-Port | `25` (Produktion: `2525`) | +| `SMTP_SECURE` | SMTPS/TLS beim Verbindungsaufbau aktivieren | `false` | +| `TLS_KEY_PATH` | TLS-Privatschlüssel, wenn `SMTP_SECURE=true` | keiner | +| `TLS_CERT_PATH` | TLS-Zertifikat, wenn `SMTP_SECURE=true` | keines | +| `SMTP_ALLOWED_IPS` | Optionale, komma-separierte Quell-IP-Allowlist | leer = alle | +| `MAX_MESSAGE_SIZE` | Maximale SMTP-Nachricht in Bytes; `0` deaktiviert das Limit | `26214400` (25 MiB) | +| `MAX_FILE_SIZE` | Maximale S3-Anhanggröße in Bytes | `5242880` (5 MiB) | +| `MAX_SMTP_CLIENTS` | Maximale parallele SMTP-Clients | `50` | +| `SMTP_SOCKET_TIMEOUT` | SMTP-Socket-Timeout in Millisekunden | `300000` | +| `SMTP_CLOSE_TIMEOUT` | Timeout für SMTP-Verbindungsabbau in Millisekunden | `10000` | +| `WEBHOOK_CONCURRENCY` | Parallele Webhook-Jobs | `5` | +| `WEBHOOK_QUEUE_RETRIES` | Queue-Wiederholungen nach einem fehlgeschlagenen Job | `10` | +| `WEBHOOK_QUEUE_RETRY_DELAY` | Wartezeit zwischen Queue-Wiederholungen in Millisekunden | `60000` | +| `WEBHOOK_QUEUE_TIMEOUT` | Maximale Dauer eines Queue-Jobs in Millisekunden | `35000` | +| `SPOOL_DIR` | Basisverzeichnis des persistenten Spools | `/var/lib/smtpwebhook/spool` | +| `LOG_DIR` | Verzeichnis der aktuellen Anwendungslogs | `/var/lib/smtpwebhook/logs` | +| `AWS_REGION` | AWS-Region | abhängig von AWS SDK | +| `AWS_ACCESS_KEY_ID` | AWS-Zugangsschlüssel | keiner | +| `AWS_SECRET_ACCESS_KEY` | AWS-Geheimschlüssel | keiner | +| `S3_BUCKET_NAME` | S3-Bucket für Anhänge | keiner | + +Die aktuelle produktive Supervisor-Definition setzt zusätzlich: + +- `NODE_ENV=production` +- `SPOOL_DIR=/var/lib/smtpwebhook/spool` +- `LOG_DIR=/var/lib/smtpwebhook/logs` +- `umask=027` +- Benutzer `smtpwebhook` + +`SMTP_ALLOWED_IPS` ist derzeit nicht gesetzt. Da SMTP AUTH deaktiviert ist, +sollte der Netzwerkzugriff durch Firewall oder IP-Allowlist auf bekannte +Absender begrenzt werden, sobald diese IPs feststehen. + +## Installation und lokaler Start + +Voraussetzungen sind Node.js 18 oder neuer, npm, ein erreichbarer Webhook und +bei aktivierter Anhangspeicherung passende S3-Berechtigungen. + +Beispiel für eine neue Installation: + +```bash +git clone https://git.hacker.schule/mherrlein/inbound-email.git +cd inbound-email +npm ci +cp .env.example .env +${EDITOR:-vi} .env +chmod 640 .env +``` + +Für einen lokalen Vordergrundtest müssen mindestens `WEBHOOK_URL` und ein nicht +privilegierter Port gesetzt sein. `dotenv` lädt `.env` relativ zum aktuellen +Arbeitsverzeichnis; daher aus dem Projektverzeichnis starten: + +```bash +PORT=25252 \ +SPOOL_DIR=/tmp/smtpwebhook-spool \ +LOG_DIR=/tmp/smtpwebhook-logs \ +npm start +``` + +Für produktive Pfade müssen die Verzeichnisse vor dem Start dem Dienstbenutzer +gehören und restriktiv geschützt sein: + +```bash +install -d -o smtpwebhook -g smtpwebhook -m 700 \ + /var/lib/smtpwebhook \ + /var/lib/smtpwebhook/spool/pending \ + /var/lib/smtpwebhook/spool/failed \ + /var/lib/smtpwebhook/logs +``` + +Die konkrete Produktivdefinition liegt außerhalb des Repositories unter +`/etc/supervisor/conf.d/inbound-email.conf`. Nach Änderung dieser Datei: + +```bash +supervisorctl -c /etc/supervisor/supervisord.conf reread +supervisorctl -c /etc/supervisor/supervisord.conf update inbound-email +supervisorctl -c /etc/supervisor/supervisord.conf status inbound-email +``` + +Bei reinem Code- oder `.env`-Update genügt normalerweise: + +```bash +supervisorctl -c /etc/supervisor/supervisord.conf restart inbound-email +``` + +## Supervisor und systemd + +Die aktuelle App-Definition verwendet unter anderem: + +- `autorestart=unexpected` +- `startsecs=5` +- `startretries=10` +- `stopsignal=TERM` +- `stopasgroup=true` und `killasgroup=true` +- `stopwaitsecs=30` +- begrenzte Supervisor-Logs mit Rotation + +Zusätzlich existiert außerhalb des Repositories der Drop-in +`/etc/systemd/system/supervisor.service.d/10-kill-children.conf`. Er setzt +`KillMode=control-group`, damit beim Neustart des Supervisor-Managers keine +verwaisten App-Prozesse zurückbleiben, sowie `Restart=on-failure` und +`RestartSec=5s`. + +Nach Änderungen am Drop-in ist nur ein Reload der Unitdefinition erforderlich: + +```bash +systemctl daemon-reload +systemctl show supervisor.service -p KillMode -p Restart -p RestartUSec +``` + +Der aktuelle Laufzeitcheck sollte mindestens Folgendes bestätigen: + +```bash +supervisorctl -c /etc/supervisor/supervisord.conf status inbound-email +ss -ltnp | grep ':2525' +ps -C node -o user,group,pid,ppid,stat,etime,args +``` + +Erwartet wird genau ein Node-Prozess für `/opt/inbound-email/server.js` unter +`smtpwebhook` und genau ein Listener auf dem konfigurierten Port. + +### Alternative: eigenständige systemd-Unit + +Wenn Supervisor auf einem Zielsystem nicht vorhanden ist, kann `inbound-email` +direkt durch systemd gestartet werden. Dafür eine Unit unter +`/etc/systemd/system/smtpwebhook.service` anlegen: + +```ini +[Unit] +Description=SMTP to Webhook bridge +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=smtpwebhook +WorkingDirectory=/opt/inbound-email +EnvironmentFile=/opt/inbound-email/.env +Environment=NODE_ENV=production +Environment=SPOOL_DIR=/var/lib/smtpwebhook/spool +Environment=LOG_DIR=/var/lib/smtpwebhook/logs +ExecStart=/usr/bin/node /opt/inbound-email/server.js +Restart=on-failure +RestartSec=5s +KillMode=control-group +TimeoutStopSec=30s +UMask=0027 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/opt/inbound-email +ReadWritePaths=/var/lib/smtpwebhook + +[Install] +WantedBy=multi-user.target +``` + +Die Pfade für Spool und Logs müssen vor dem Start existieren und dem Benutzer +`smtpwebhook` gehören. Auf Systemen mit anderer Benutzer- oder Gruppenstruktur +die Unit und die Dateirechte entsprechend anpassen. Danach: + +```bash +systemctl daemon-reload +systemctl enable --now smtpwebhook.service +systemctl status smtpwebhook.service +journalctl -u smtpwebhook.service -f +``` + +Supervisor und diese systemd-App-Unit dürfen niemals gleichzeitig dieselbe +Instanz starten. Bei einer Umstellung zuerst den `inbound-email`-Block aus der +Supervisor-Konfiguration entfernen oder deaktivieren, danach `reread`/`update` +ausführen und erst dann `smtpwebhook.service` aktivieren. Den Supervisor selbst +nicht stoppen, wenn er noch andere Programme wie Laravel-Worker verwaltet. +Beim Wechsel zurück zu Supervisor die systemd-Unit zuerst stoppen und +deaktivieren, anschließend den Supervisor-Block wieder aktivieren. + +## Logging und Fehleranalyse + +Die Hauptlogs von `server.js` werden als JSON in `LOG_DIR` geschrieben und von +Winston täglich sowie ab 20 MiB rotiert; Aufbewahrung ist auf ungefähr 90 Tage +begrenzt. Supervisor führt zusätzlich die Dateien +`/var/log/inbound-email.out.log` und `/var/log/inbound-email.err.log`. + +Bei Problemen in dieser Reihenfolge prüfen: + +1. `supervisorctl ... status inbound-email` +2. Portbelegung mit `ss -ltnp` +3. aktuelle Datei unter `/var/lib/smtpwebhook/logs` +4. Anzahl und Alter der Dateien in `spool/pending` und `spool/failed` +5. HTTP-Status und Erreichbarkeit des Webhooks +6. S3-Berechtigungen und Netzwerkzugriff, falls Anhänge verarbeitet werden + +Die Einträge `EADDRINUSE` im Log weisen auf eine doppelte oder verwaiste +Instanz hin. Nicht einfach eine zweite App starten; zuerst den Besitzer des +Ports identifizieren und den alten Prozess kontrolliert beenden. + +Die EPIPE-Probleme vom August 2026 wurden durch die frühere Winston-Console- +Transport-Konfiguration begünstigt. Die Hauptlogger-Konfiguration verwendet +deshalb einen File-Transport. Neue `console.log`-/`console.error`-Ausgaben in +der App sollten vermieden und bei Gelegenheit über denselben Logger geführt +werden. + +## Änderungen, Tests und Git + +Vor Änderungen zuerst `git status` und die bestehende Konfiguration lesen. Das +Repository enthält eine abweichende, gehärtete Weiterentwicklung des ursprüng- +lichen GitHub-Projekts. `upstream` zeigt weiterhin auf das Original; `origin` +zeigt auf `https://git.hacker.schule/mherrlein/inbound-email`. + +Mindestens diese Prüfungen ausführen: + +```bash +node --check server.js +node --check config.js +git diff --check +``` + +Aktuell existieren keine automatisierten Testfälle. Ein SMTP-Handshake gegen +`localhost` prüft nur Listener und Protokollannahme; er testet weder S3 noch den +Webhook und darf keine Testmail an ein produktives Ziel senden. + +Vor einem Commit: + +- `.env`, private Schlüssel, Logs, Spool-Dateien und `node_modules` dürfen nicht + in den Index gelangen. +- `git diff --cached --name-only` und `git diff --cached --check` prüfen. +- `package-lock.json` mit den Änderungen synchron halten; bevorzugt `npm ci` bei + einer sauberen Installation verwenden. +- Keine History des Ziel-Repositories per Force-Push überschreiben. +- Ein Push zu einem externen Remote benötigt eine explizite Benutzerfreigabe. + +## Sicherheits- und Änderungsfallen + +- `WEBHOOK_URL` niemals hartcodieren und keinen externen Default hinzufügen. +- `.env` enthält Zugangsdaten und sollte produktiv etwa `root:` + gehören, Modus `640`; der Dienst benötigt nur Leserechte. +- TLS-Privatschlüssel nicht nach `/opt/inbound-email` kopieren. Bei + `SMTP_SECURE=true` müssen `TLS_KEY_PATH` und `TLS_CERT_PATH` für + `smtpwebhook` lesbar sein. +- Der SMTP-Server deaktiviert AUTH und ist bei leerer Allowlist für alle + erreichbaren IPs offen. Netzwerkfreigaben daher bewusst konfigurieren. +- Spool-Zustellung nicht wieder vollständig in eine reine In-Memory-Queue + zurückbauen. Die Queue ist nur der Dispatcher; die JSON-Datei ist die + Restart-Garantie. +- Bei Änderungen an `onData` immer sicherstellen, dass die Nachricht vor + `callback()` persistent gespeichert ist. +- Beim Löschen oder Verschieben von Spool-Dateien zuerst Zweck, Alter und Inhalt + prüfen. Das Verzeichnis ist operative Nutzdatenhaltung, kein Cache. +- `DockerFile` baut derzeit aus dem öffentlichen GitHub-Original und bildet die + lokale gehärtete Installation nicht automatisch ab. Nicht für ein produktives + Update verwenden, ohne Clone-URL, Branch, Spool-Persistenz und Logging zu + prüfen bzw. anzupassen. +- `readme.md` enthält überwiegend die ursprüngliche Projektdokumentation; + diese `AGENTS.md` beschreibt die aktuelle Serverinstallation und hat für + Codex-Arbeiten Vorrang.