Compare commits

..

7 Commits

Author SHA1 Message Date
0d49d5fa72 rspamd rule 2026-08-15 15:09:41 +02:00
691c7bd1ec rspamd_report.sh + train_bayes.sh mit Mehrfach-Filter
rspamd_report.sh wertet /var/log/mail/rspamd.log lesbar aus: Uebersicht als
Tabelle (Score, Aktion, Scanzeit, Von, An) und Detailansicht einer Mail mit
nach Gewicht sortierter Symbolaufschluesselung samt Summe. Getestet gegen
echte Logzeilen, die Summe deckt sich mit dem Score im Log.

train_bayes.sh: --only akzeptiert jetzt mehrere Muster (mehrfach angebbar
oder kommasepariert), damit sich das Training auf kuratierte Postfaecher
begrenzen laesst. Ohne Filter wird das jetzt explizit ausgegeben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:30:33 +02:00
f10cc230af fuzzy_check abschalten - Storage nicht erreichbar
Jede eingehende Mail lief in einen IO-Timeout gegen fuzzy1/fuzzy2.rspamd.com
auf UDP 11335. Der rspamd-Default (timeout = 2s, retransmits = 1) kostet damit
zwei Versuche a 2 Sekunden, also die konstanten ~4,3s Scanzeit, die unabhaengig
von Nachrichtengroesse und DNS-Resolver zu beobachten waren.

Die Server werden korrekt aufgeloest, es scheitert an der UDP-Verbindung -
vermutlich Egress-Filterung. In der Datei steht dokumentiert, wie sich das mit
`rspamadm fuzzy_ping` pruefen und das Modul wieder aktivieren laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:54:12 +02:00
1ef0b96be4 DNS-Recursor fuer rspamd + Bayes-Trainingsskript
Rspamds DNS-Blocklisten waren wirkungslos: Spamhaus beantwortete keine
Anfragen, weil sie ueber Dockers eingebautes DNS von einem als oeffentlich
eingestuften Resolver kamen (DBL_BLOCKED_OPENRESOLVER im Log, fuer rDNS,
Message-ID und URLs gleichermassen). ENABLE_UNBOUND=1 hat daran nichts
geaendert - die Variable existiert in DMS nicht und war ein No-Op.

- dns-recursor (PowerDNS Recursor 5.3.10) als eigener Service, der selbst
  rekursiv aufloest, in einem separaten Netz dms_dns mit fester IP, weil
  Dockers dns:-Option nur IPs akzeptiert
- ENABLE_UNBOUND entfernt
- train_bayes.sh: einmaliges Anlernen des Bayes-Klassifikators aus den
  vorhandenen Postfaechern (Junk -> Spam, INBOX -> Ham). Bisher feuerte kein
  einziges BAYES_-Symbol, weil die noetigen 200 Lernvorgaenge je Klasse
  fehlten. Default ist ein Trockenlauf, gelernt wird nur mit --apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:43:02 +02:00
0978154f43 relay_port 2026-08-14 23:18:48 +02:00
9fdd3408a0 rspamd 2026-08-14 23:07:21 +02:00
1eb46d98ef new config_url 2026-08-14 17:21:15 +02:00
11 changed files with 1001 additions and 64 deletions

232
DMS/README.md Normal file
View File

@@ -0,0 +1,232 @@
# Spam-Scoring im Mailfluss — Stand und offene Punkte
Dieses Dokument hält fest, warum die rspamd-Konfiguration hier vom Standard
abweicht und was noch offen ist. Hintergrund, der sich weder aus der Config
noch aus der Git-History erschließt, gehört hierher.
## Mailfluss
```
Externe Mail → SES → S3/SQS → email-worker (Container) → SMTP :25 → DMS
```
Daraus folgt die zentrale Eigenheit: **rspamd sieht als Client-IP immer den
Worker-Container**, nie den echten Absender-Server. Jede verbindungsbezogene
Prüfung misst damit unsere eigene Infrastruktur. Ohne Gegenmaßnahme fallen
systematisch Strafpunkte an, die nichts mit der Mail zu tun haben:
| Symbol | Gewicht |
|---|---|
| `HFILTER_HOSTNAME_UNKNOWN` | 6.0 (kein PTR für 172.x — exakt die `add_header`-Schwelle) |
| `R_SPF_FAIL` / `R_SPF_SOFTFAIL` | 4.5 / 2.5 |
| `R_DKIM_NA` / `R_DKIM_PERMFAIL` | 1.0 / 4.5 |
SPF/DKIM/DMARC gehen dadurch nicht verloren: SES prüft sie bereits mit der
echten Absender-IP, das Ergebnis liegt im SQS-Payload
(`receipt.spamVerdict`, `spfVerdict`, `dkimVerdict`, `dmarcVerdict`).
## Aktueller Stand
Schwellwerte in `docker-data/dms/config/rspamd/override.d/actions.conf`:
`add_header = 6`, `reject = 500` (praktisch nie), `greylist = 500` (aus).
Mit `MOVE_SPAM_TO_JUNK=1` bedeutet ein Score ab 6 also: Mail landet im Junk.
Die Settings-Regeln stehen in
`docker-data/dms/config/rspamd/append.d/settings.conf`, ausgewertet nach
Priorität, es greift nur der erste Treffer:
| Prio | Regel | Gilt für | Wirkung |
|---|---|---|---|
| 20 | `ses_worker_forwarded` | `X-SES-Worker-Processed: forwarded\|ooo-reply` | Auth-Gruppen aus **plus** `SPOOF_REPLYTO`, `FREEMAIL_REPLYTO*` |
| 10 | `ses_worker_injected` | Worker-Header vorhanden (also `delivered`) | nur Auth-Gruppen aus |
| 5 | `local_reinjection` | Docker-Netz ohne Worker-Header (Sieve-Redirects, Roundcube) | ersetzt die alte `DOCKER_WL`-Whitelist |
Alle drei sind zusätzlich an `172.16.0.0/12` / `127.0.0.0/8` gebunden. Ohne
diese IP-Bedingung könnte ein externer Absender den Header selbst setzen und
die Prüfungen abschalten.
**Was bewusst entfernt wurde:** die frühere `DOCKER_WL`-Regel (-50 Punkte für
jede Mail mit eigener Absenderdomain) samt erzwungenem „no action" aus
`force_actions.conf`. Sie war ein Spoofing-Loch — mit `SPOOF_PROTECTION=0`
genügte ein `MAIL FROM: <x@bayarea-cc.com>` für garantierte Zustellung — und
hat echten Spam an weitergeleitete Adressen durchgereicht.
---
## Offener Punkt B: Weiterleitungen ohne Neukomposition
**Status:** offen, bewusst zurückgestellt. Punkt A (die Settings-Regel
`ses_worker_forwarded`) ist am 2026-08-15 umgesetzt worden und behebt das
Symptom. B ist die strukturelle Lösung.
### Das Problem
`buildForwardMessage()` in
`email-worker-nodejs/src/email/rules-processor.ts:212` baut für jede
Weiterleitung eine **neue** Mail:
```ts
from: recipient, // support@bayarea-cc.com — die getroffene lokale Adresse
to: forwardTo, // ebrunks@bayarea-cc.com — das Weiterleitungsziel
subject: `FWD: ${originalSubject}`,
replyTo: originalFrom, // andreas.knuth@gmail.com — der Originalabsender
```
Bei einer Weiterleitung an einen Kollegen in derselben Domain ergibt das
formal die Signatur eines CEO-Fraud-Angriffs: Absender und Empfänger intern,
die Antwort geht nach außen an eine Freemail-Adresse.
Belegt an Queue-ID `F228E320085` (2026-08-15 07:53:38):
```
6.00 SPOOF_REPLYTO bayarea-cc.com;gmail.com
2.00 FREEMAIL_REPLYTO_NEQ_FROM
-0.10 MIME_GOOD
0.05 MANY_INVISIBLE_PARTS
----
7.95 → add header → Junk
```
Dieselbe Mail als Original an `support@` (`EC702320085`, eine Sekunde später):
**-1.05, no action**. Der Unterschied kommt allein aus der Bauform der
Weiterleitung, nicht aus dem Inhalt.
### Der Vorschlag
Statt eine neue Mail zu komponieren, die Originalmail **unverändert
weiterreichen** (Remailing): `From:` bleibt der Originalabsender,
Envelope-From wird eine lokale Bounce-Adresse, kein `Reply-To` mehr,
kein `FWD:`-Präfix.
Vorteile über die Spam-Bewertung hinaus:
- Threading in den Clients bleibt intakt (`Message-ID`, `References`)
- Originalbetreff und Originalheader bleiben erhalten
- Anhänge müssen nicht neu verpackt werden
- Das Muster, das `SPOOF_REPLYTO` auslöst, entsteht gar nicht erst — die
Ausnahme in `ses_worker_forwarded` könnte danach wieder enger gefasst
oder ganz entfernt werden
### Warum es nicht einfach nur „besser" ist
Das ist ein echter Eingriff mit Folgen, die vor der Umsetzung geklärt werden
müssen:
1. **Bounces.** Envelope-From muss auf eine Adresse zeigen, die wir selbst
auswerten — sonst gehen Zustellfehler an den Originalabsender, der mit
unserer Weiterleitung nichts zu tun hat. Klassische Lösung ist SRS;
`ENABLE_SRS=0` ist derzeit gesetzt.
2. **DMARC bei externen Zielen — das ist der harte Punkt.**
`handleForwards()` unterscheidet: interne Ziele gehen über
`sendInternalEmail()` auf Port 25, externe über `ses.sendRawEmail()`.
Beide setzen den Envelope-From auf `recipient`, also auf unsere eigene
Domain. Heute ist das für externe Weiterleitungen sogar ein Vorteil: SES
signiert mit unserem DKIM, SPF passt, und weil `From:` ebenfalls unsere
Domain ist, ist DMARC sauber ausgerichtet.
Genau das würde Remailing zerstören. Mit `From:` = Originalabsender prüft
Gmail DMARC gegen dessen Domain — und wir erfüllen weder deren SPF (die
Mail kommt von SES) noch deren DKIM (wir haben den Body umgebaut).
Das ist das klassische Forwarding-Problem. SRS löst den SPF-Teil,
`ENABLE_SRS=0` ist derzeit gesetzt; für DKIM hilft nur, die Mail wirklich
**byte-identisch** durchzureichen, inklusive Originalheader.
Praktische Konsequenz: B ließe sich zunächst **nur für interne Ziele**
umsetzen und für externe beim jetzigen Verhalten bleiben. Das löst den
`SPOOF_REPLYTO`-Fall — der tritt ohnehin nur intern auf, weil er
`From`-Domain == `To`-Domain voraussetzt — ohne die externe
Zustellbarkeit anzufassen.
3. **Absenderdarstellung.** Im Posteingang steht dann der Originalabsender,
nicht mehr `support@`. Ob das gewünscht ist, ist eine fachliche
Entscheidung — Mailinglisten lösen es über
`From: "Name via support" <support@bayarea-cc.com>`, was allerdings
`SPOOF_REPLYTO` teilweise zurückholt.
### Abnahmekriterium
Eine Weiterleitung einer externen Mail an eine interne Adresse zeigt in
`rspamd_report.sh --detail` einen Score unter 6 **ohne** die Ausnahme für
`SPOOF_REPLYTO`, und ein Reply des Empfängers erreicht den Originalabsender.
---
## Weitere offene Punkte
### `fuzzy_check` wieder einschalten
Abgeschaltet in `docker-data/dms/config/rspamd/local.d/fuzzy_check.conf`, weil
UDP 11335 ausgehend nicht durchkommt (vermutlich Egress-Filter des Providers).
Jede Mail lief in denselben Timeout und kostete konstant ~4,3 s Scanzeit ohne
jeden Gegenwert. Nach dem Abschalten liegen die Scans bei 70580 ms.
Fuzzy erkennt Massen-Spam über Hashes — genau die Klasse, die weder
Auth-Prüfungen noch ein frisch angelernter Bayes zuverlässig erwischt. Das
lohnt sich mit dem Provider zu klären. Test:
```
docker exec mailserver rspamadm fuzzy_ping -s rspamd.com
```
### Bayes anlernen
`BAYES_*` feuert erst ab 200 gelernten Nachrichten **je Klasse**, aktuell also
gar nicht. `docker-data/dms/config/train_bayes.sh` lernt einmalig aus den
vorhandenen Postfächern (Junk → Spam, INBOX → Ham), Default ist Trockenlauf.
Wichtig: **nur kuratierte Postfächer** über `--only` einbeziehen. Ohne Filter
läuft das Skript über alle Postfächer aller Domains unter `/var/mail` — bei
Weiterleitungsadressen ist die INBOX ein ungefilterter Durchlauf und als
Ham-Quelle unbrauchbar.
Reihenfolge beachten: erst falsch einsortierte Mail abstellen, dann lernen.
Solange legitime Weiterleitungen im Junk landen, lernt `RSPAMD_LEARN=1` sie
dort automatisch als Spam an und vergiftet den Klassifikator.
### SES-Verdicts als Header stempeln
Der Worker könnte `receipt.spfVerdict` / `dkimVerdict` / `dmarcVerdict` aus
dem SQS-Payload als Header setzen. Dann ließen sich die echten,
mit der richtigen Absender-IP ermittelten Auth-Ergebnisse per multimap wieder
bepunkten, statt sie ersatzlos abzuschalten.
---
## Betrieb
### Auswertung
```
docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh -n 20
docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh --detail <QID>
docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh --slow 1000
```
Das Feld `Regel:` in der Detailansicht zeigt, welche Settings-Regel gegriffen
hat — der schnellste Weg zu prüfen, ob eine Änderung überhaupt aktiv ist.
Voraussetzung ist `level = "info"` in `rspamd/local.d/logging.inc`; der
DMS-Default `silent` protokolliert nichts.
### Änderungen an `append.d/` ausrollen
`user-patches.sh` hängt die Dateien aus `rspamd/append.d/` an die
`local.d`-Pendants an und überspringt das, wenn der Marker `DMS-CUSTOM-APPEND`
dort schon steht. `/etc/rspamd` liegt im Container-Dateisystem und überlebt
`docker restart`**ein Neustart übernimmt Änderungen also nicht.** Nötig ist:
```
docker compose up -d --force-recreate mailserver
```
Das Anhängen statt Überschreiben ist zwingend: DMS legt in `settings.conf`
selbst eine Regel für authentifizierte User ab (`DMS::SED_TAG::1`), die ein
`cp` löschen würde — womit `RSPAMD_CHECK_AUTHENTICATED=0` wirkungslos wäre.
### DNS
rspamds Blocklisten brauchen einen eigenen Recursor. Über Dockers eingebautes
DNS (127.0.0.11) stufte Spamhaus die Anfragen als von einem offenen Resolver
kommend ein und antwortete nur mit `DBL_BLOCKED_OPENRESOLVER`. Gelöst über den
Service `dns-recursor` (PowerDNS) im eigenen Netz `dms_dns` mit fester IP —
fest deshalb, weil Dockers `dns:`-Option nur IPs akzeptiert.
`ENABLE_UNBOUND` existiert in DMS nicht und war ein No-Op.

View File

@@ -26,7 +26,13 @@ services:
- ./docker-data/dms/mail-state/:/var/mail-state/ - ./docker-data/dms/mail-state/:/var/mail-state/
- ./docker-data/dms/mail-logs/:/var/log/mail/ - ./docker-data/dms/mail-logs/:/var/log/mail/
- ./docker-data/dms/config/:/tmp/docker-mailserver/ - ./docker-data/dms/config/:/tmp/docker-mailserver/
- ./docker-data/dms/config/dovecot/conf.d/95-sieve-redirect.conf:/etc/dovecot/conf.d/95-sieve-redirect.conf:ro # ENTFERNT: Mount von dovecot/conf.d/95-sieve-redirect.conf
# Der Host-Pfad existiert nicht, Docker hat daraufhin ein VERZEICHNIS
# unter /etc/dovecot/conf.d/95-sieve-redirect.conf angelegt (nachweisbar
# per `docker exec mailserver cat ...` -> "Is a directory"). Der Mount war
# also wirkungslos. Falls dort spaeter wirklich etwas gebraucht wird
# (z.B. sieve_redirect_envelope_from), erst die Datei auf dem Host
# anlegen, dann den Mount wieder aufnehmen.
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- ./sync_dynamodb_to_sieve.py:/scripts/sync.py:ro - ./sync_dynamodb_to_sieve.py:/scripts/sync.py:ro
- ./sieve-cron:/etc/cron.d/sieve-sync:ro - ./sieve-cron:/etc/cron.d/sieve-sync:ro
@@ -65,7 +71,12 @@ services:
- SSL_KEY_PATH=/etc/mail/certs/${NODE_HOSTNAME}/${NODE_HOSTNAME}.key - SSL_KEY_PATH=/etc/mail/certs/${NODE_HOSTNAME}/${NODE_HOSTNAME}.key
# SPAM / Rspamd # SPAM / Rspamd
- ENABLE_OPENDKIM=1 # OpenDKIM deaktiviert: es liegen keine Keys unter config/opendkim/, der
# Milter hat also nie etwas signiert. Ausgehende Mail wird von Amazon SES
# signiert (Easy DKIM), eingehende DKIM-Pruefung macht rspamd selbst.
# DMS warnt beim Start ausdruecklich davor, OpenDKIM parallel zu rspamd
# zu betreiben.
- ENABLE_OPENDKIM=0
- ENABLE_OPENDMARC=0 - ENABLE_OPENDMARC=0
- ENABLE_POLICYD_SPF=0 - ENABLE_POLICYD_SPF=0
- ENABLE_RSPAMD=1 - ENABLE_RSPAMD=1
@@ -80,7 +91,10 @@ services:
# Sicherheit # Sicherheit
- ENABLE_FAIL2BAN=1 - ENABLE_FAIL2BAN=1
- ENABLE_UNBOUND=1 # ENABLE_UNBOUND entfernt: die Variable existiert in DMS nicht (mehr) und
# war wirkungslos - rspamd hat ueber Dockers eingebautes DNS (127.0.0.11)
# aufgeloest und bekam von Spamhaus nur DBL_BLOCKED_OPENRESOLVER zurueck.
# Ersetzt durch den dns-recursor-Service weiter unten.
# Sonstige # Sonstige
- ENABLE_MANAGESIEVE=0 - ENABLE_MANAGESIEVE=0
@@ -95,7 +109,10 @@ services:
# Amazon SES Relay # Amazon SES Relay
- RELAY_HOST=email-smtp.us-east-2.amazonaws.com - RELAY_HOST=email-smtp.us-east-2.amazonaws.com
- RELAY_PORT=587 # Node-spezifisch: manche Provider blockieren ausgehend 587.
# Wert pro Server in der .env setzen (z.B. RELAY_PORT=2587),
# ohne Eintrag bleibt es bei 587.
- RELAY_PORT=${RELAY_PORT:-587}
- RELAY_USER=${SES_SMTP_USER} - RELAY_USER=${SES_SMTP_USER}
- RELAY_PASSWORD=${SES_SMTP_PASSWORD} - RELAY_PASSWORD=${SES_SMTP_PASSWORD}
@@ -116,11 +133,47 @@ services:
- NET_ADMIN - NET_ADMIN
- SYS_PTRACE - SYS_PTRACE
restart: unless-stopped restart: unless-stopped
# -------------------------------------------------------
# DNS: rspamd fragt DNS-Blocklisten (Spamhaus DBL/ZEN, SURBL) ab. Diese
# Dienste beantworten keine Anfragen, die ueber oeffentliche Resolver
# kommen - man bekommt DBL_BLOCKED_OPENRESOLVER statt eines Ergebnisses.
# Deshalb ein eigener rekursiver Resolver, siehe Service dns-recursor.
#
# ACHTUNG: Damit ersetzt der Recursor Dockers eingebautes DNS (127.0.0.11)
# fuer DIESEN Container. Der mailserver kann dann keine Container-Namen
# mehr aufloesen (roundcube-db, email-worker-ts, ...). Das ist hier
# unkritisch, weil er von sich aus nur nach aussen verbindet (SES-Relay).
# Faellt der Recursor aus, hat der mailserver kein DNS mehr - deshalb
# restart: always beim Recursor.
# -------------------------------------------------------
dns:
- 10.99.0.53
depends_on:
- dns-recursor
networks: networks:
mail_network: mail_network:
aliases: aliases:
- mailserver - mailserver
- ${NODE_HOSTNAME} - ${NODE_HOSTNAME}
dns_network:
# ---------------------------------------------------------
# Rekursiver DNS-Resolver ausschliesslich fuer den mailserver.
# Er fragt die Root-Server selbst ab, statt an einen Upstream-Resolver
# weiterzuleiten - nur so akzeptiert Spamhaus die Anfragen.
# Braucht keine Konfiguration: das Image laeuft per Default als Recursor
# und erlaubt Anfragen aus den RFC1918-Netzen (10.99.0.0/24 liegt darin).
# ---------------------------------------------------------
dns-recursor:
image: powerdns/pdns-recursor-53:5.3.10
container_name: dns-recursor
restart: always
stop_grace_period: 0s
networks:
dns_network:
ipv4_address: 10.99.0.53
roundcube: roundcube:
image: roundcube/roundcubemail:latest image: roundcube/roundcubemail:latest
@@ -176,3 +229,16 @@ services:
networks: networks:
mail_network: mail_network:
external: true external: true
# Eigenes Netz nur fuer mailserver <-> dns-recursor.
# Bewusst NICHT im externen mail_network: dort vergibt Docker die Adressen
# dynamisch, eine feste IP koennte mit einem anderen Container kollidieren.
# Das Subnetz muss fest sein, weil Dockers `dns:`-Option nur IPs akzeptiert.
# Falls 10.99.0.0/24 bei euch schon belegt ist (VPN, AWS-VPC), hier und bei
# `dns:` sowie `ipv4_address` oben gemeinsam auf ein freies Netz aendern.
dns_network:
name: dms_dns
ipam:
driver: default
config:
- subnet: 10.99.0.0/24

View File

@@ -0,0 +1,135 @@
# DMS-CUSTOM-APPEND
# ===========================================================================
# WICHTIG: Diese Datei wird von user-patches.sh an /etc/rspamd/local.d/settings.conf
# ANGEHAENGT und nicht darueber kopiert. DMS legt in dieser Datei selbst eine
# Regel fuer authentifizierte User ab (DMS::SED_TAG::1), die erhalten bleiben muss.
# ===========================================================================
#
# Hintergrund
# -----------
# Externe Mail erreicht DMS nie direkt: SES -> S3/SQS -> email-worker -> SMTP.
# Rspamd sieht als Client-IP daher immer den Worker-Container (172.x) statt des
# echten Absender-Servers. Alle verbindungsbezogenen Pruefungen messen damit
# unsere eigene Infrastruktur und liefern systematisch Strafpunkte:
#
# HFILTER_HOSTNAME_UNKNOWN 6.0 (kein PTR fuer 172.x -> exakt die add_header-Schwelle)
# R_SPF_FAIL / R_SPF_SOFTFAIL 4.5 / 2.5
# R_DKIM_NA / R_DKIM_PERMFAIL 1.0 / 4.5
#
# Diese Gruppen werden fuer Einlieferungen aus dem Docker-Netz deaktiviert.
# Inhaltsbezogene Pruefungen (Bayes, Fuzzy, URL-/Surbl-Blocklisten, MIME,
# Attachment-Checks) bleiben vollstaendig aktiv - echter Spam wird also weiter
# erkannt, nur eben anhand des Inhalts statt anhand einer Transportstrecke,
# die rspamd hier gar nicht beurteilen kann.
#
# SPF/DKIM/DMARC gehen dadurch nicht verloren: SES prueft sie bereits mit der
# echten Absender-IP. Das Ergebnis liegt im SQS-Payload (receipt.spamVerdict,
# spfVerdict, dkimVerdict, dmarcVerdict). Sobald der Worker diese Verdicts als
# Header stempelt, koennen sie hier per multimap wieder bepunktet werden.
# ---------------------------------------------------------------------------
# 1) Vom Worker SELBST komponierte Mail: Weiterleitungen und OOO-Antworten
#
# Zusaetzlich zu den Auth-Symbolen aus Regel 2 fallen hier inhaltliche Symbole
# an, die auf die Bauform der Weiterleitung reagieren. buildForwardMessage()
# in email-worker-nodejs/src/email/rules-processor.ts setzt:
#
# From: die getroffene lokale Adresse (support@bayarea-cc.com)
# To: das Weiterleitungsziel (ebrunks@bayarea-cc.com)
# Reply-To: den Originalabsender (andreas.knuth@gmail.com)
#
# Damit sieht jede Weiterleitung an einen Kollegen in derselben Domain formal
# aus wie CEO-Fraud: Absender und Empfaenger intern, Antwort geht nach aussen
# an eine Freemail-Adresse. Rspamd bepunktet das - zu Recht, nur ist die
# Praemisse hier falsch, weil wir die Mail selbst gebaut haben:
#
# SPOOF_REPLYTO 6.0 From-Domain == To-Domain != Reply-To-Domain
# FREEMAIL_REPLYTO_NEQ_FROM 2.0 Reply-To ist Freemail und != From
#
# Belegt an F228E320085 (2026-08-15 07:53:38): Score 7.95, also ueber
# add_header = 6 - die Weiterleitung landete im Junk, waehrend dieselbe Mail
# als Original an support@ mit -1.05 sauber durchlief (EC702320085).
#
# WARUM EINE EIGENE REGEL statt symbols_disabled in Regel 2:
# Regel 2 gilt auch fuer "delivered", also fuer echte eingehende Fremdmail.
# Dort ist SPOOF_REPLYTO genau das Signal, das BEC-Angriffe erkennt - das
# darf nicht verloren gehen. Diese Regel prueft deshalb den WERT des Headers
# und greift nur fuer Mail aus unserer eigenen Komposition.
#
# Die Auth-Gruppen muessen wiederholt werden: rspamd wertet nach Prioritaet
# aus und wendet nur die erste passende Regel an, Regel 2 greift hier also
# nicht mehr.
#
# Falls der Regexp-Vergleich nicht zieht, faellt die Mail auf Regel 2 zurueck
# und behaelt ihre 7.95 - der Fehler ist damit sichtbar, nicht still. Pruefen
# laesst sich das am Feld "Regel:" in:
# docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh --detail <QID>
# Erwartet wird ses_worker_forwarded.
# ---------------------------------------------------------------------------
ses_worker_forwarded {
priority = 20;
ip = ["172.16.0.0/12", "127.0.0.0/8"];
header = {
"X-SES-Worker-Processed" = "^(forwarded|ooo-reply)$";
}
apply {
groups_disabled = ["spf", "dkim", "dmarc", "hfilter"];
symbols_disabled = ["RDNS_NONE", "SPOOF_REPLYTO",
"FREEMAIL_REPLYTO_NEQ_FROM", "FREEMAIL_REPLYTO"];
}
}
# ---------------------------------------------------------------------------
# 2) Uebrige Einlieferungen des email-workers (externe Mail, "delivered")
#
# Der Worker setzt X-SES-Worker-Processed auf JEDER Mail, die er einliefert:
# delivered = Originalmail an das lokale Postfach -> diese Regel
# forwarded = neu komponierte Weiterleitung -> Regel 1
# ooo-reply = Abwesenheitsantwort -> Regel 1
#
# Hier werden ausschliesslich die Transportpruefungen abgeschaltet. Alle
# inhaltlichen Symbole bleiben aktiv, insbesondere SPOOF_REPLYTO.
#
# Die IP-Bedingung ist die Absicherung: ohne sie koennte ein externer Absender
# den Header selbst setzen und die Auth-Pruefungen abschalten. Beide
# Bedingungen werden von rspamd implizit mit UND verknuepft.
# ---------------------------------------------------------------------------
ses_worker_injected {
priority = 10;
ip = ["172.16.0.0/12", "127.0.0.0/8"];
header = {
"X-SES-Worker-Processed" = true;
}
apply {
groups_disabled = ["spf", "dkim", "dmarc", "hfilter"];
symbols_disabled = ["RDNS_NONE"];
}
}
# ---------------------------------------------------------------------------
# 3) Lokale Re-Injection ohne Worker-Header
#
# Betrifft:
# - Sieve-Redirects fuer INTERNE Mail (accounting@ -> support@ -> Forwards).
# Diese Mail laeuft nie ueber SES/Worker, hat also keinen Worker-Header.
# - Roundcube, das ohne SASL-Auth aus dem Docker-Netz einliefert
# (ROUNDCUBEMAIL_SMTP_USER ist bewusst leer).
#
# Beides wurde bisher von der DOCKER_WL-Whitelist (-50) aufgefangen. Diese
# Regel ersetzt sie - deutlich enger gefasst, weil sie an das Docker-Netz
# gebunden ist statt an eine faelschbare Absenderdomain.
#
# Niedrigste Prioritaet: rspamd wertet Regeln nach Prioritaet aus und nimmt
# den ersten Treffer, Worker-Mail landet also immer in Regel 1 oder 2.
#
# ACHTUNG: Diese Regel setzt voraus, dass Port 25 NICHT oeffentlich erreichbar
# ist (docker-compose.yml bindet ihn auf 127.0.0.1). Wird das je geaendert,
# muss diese Regel weg - sonst umgeht jeder externe Absender die Pruefung.
# ---------------------------------------------------------------------------
local_reinjection {
priority = 5;
ip = ["172.16.0.0/12", "127.0.0.0/8"];
apply {
groups_enabled = ["dkim"];
}
}

View File

@@ -0,0 +1,42 @@
# documentation: https://rspamd.com/doc/modules/fuzzy_check.html
#
# Das Modul ist abgeschaltet, weil die oeffentliche Fuzzy-Storage von
# rspamd.com von diesem Server aus nicht erreichbar ist. Im Log lief JEDE
# eingehende Mail in denselben Timeout:
#
# fuzzy_check_timer_callback: got IO timeout with server
# fuzzy1.rspamd.com:11335(80.241.57.6:11335), after 1/1 retransmits
#
# Die Server werden sauber aufgeloest, es scheitert also nicht an DNS,
# sondern an der UDP-Verbindung auf Port 11335 (vermutlich Egress-Filter
# des Providers).
#
# Kosten des Defaults: timeout = 2s bei retransmits = 1, also zwei Versuche
# a 2 Sekunden = ~4 Sekunden Wartezeit pro Mail - genau die konstanten
# 4,3 Sekunden Scanzeit, die im Log zu sehen waren. Ohne jeden Gegenwert,
# da nie eine Antwort kommt.
#
# Zusaetzlich ein Betriebsrisiko: DMS setzt soft_reject_on_timeout = true.
# Solange nur dieses eine Modul haengt, passiert nichts; laeuft der Task als
# Ganzes in den Timeout, wird Mail temporaer abgelehnt statt zugestellt.
#
# ---------------------------------------------------------------------------
# WIEDER EINSCHALTEN, sobald UDP 11335 ausgehend erreichbar ist:
# Pruefen laesst sich das direkt mit rspamds eigenem Werkzeug:
#
# docker exec mailserver rspamadm fuzzy_ping -s rspamd.com
#
# Antwortet die Storage, diese Datei loeschen (dann gilt wieder der Default)
# oder die Zeile unten auskommentieren und stattdessen die Timeouts knapp
# halten, damit ein erneuter Ausfall nicht wieder 4 Sekunden kostet:
#
# timeout = 1s;
# retransmits = 0;
#
# Fuzzy ist inhaltlich wertvoll - es erkennt Massen-Spam ueber Hashes, also
# genau die Klasse, die weder von Auth-Pruefungen noch von einem frisch
# angelernten Bayes zuverlaessig erwischt wird. Es lohnt sich, das mit dem
# Provider zu klaeren, statt es dauerhaft abgeschaltet zu lassen.
# ---------------------------------------------------------------------------
enabled = false;

View File

@@ -0,0 +1,19 @@
# documentation: https://rspamd.com/doc/configuration/logging.html
#
# Uebersteuert den DMS-Default (level = "silent"), bei dem rspamd ueberhaupt
# nichts protokolliert und Spam-Scores nicht nachvollziehbar sind.
#
# type = "console" bleibt bewusst stehen: Supervisor leitet stdout/stderr von
# rspamd bereits nach /var/log/mail/rspamd.log um (siehe dms-services.conf).
# Ein eigenes type = "file" mit demselben Pfad haette zwei Schreiber auf
# derselben Datei zur Folge.
#
# Mit level = "info" schreibt rspamd pro Mail eine Zeile mit Aktion, Score und
# allen ausgeloesten Symbolen samt Einzelgewicht:
#
# docker exec mailserver sh -c 'grep rspamd_task_write_log /var/log/mail/rspamd.log | tail -20'
type = "console";
level = "info";
color = false;
systemd = false;

View File

@@ -1,8 +1,18 @@
DOCKER_WL { # documentation: https://rspamd.com/doc/modules/multimap.html
type = "from"; #
filter = "email:domain"; # Die fruehere Regel DOCKER_WL (-50 Punkte fuer jede Mail, deren Envelope-From
map = "/etc/rspamd/override.d/docker_whitelist.map"; # auf eine unserer eigenen Domains zeigt) ist bewusst entfernt worden:
symbol = "DOCKER_WHITELIST"; #
description = "Whitelist fuer eigene Domains"; # 1. Sie war ein Spoofing-Loch. Mit SPOOF_PROTECTION=0 genuegte einem
score = -50.0; # beliebigen externen Absender ein "MAIL FROM: <x@bayarea-cc.com>", um
} # -50 Punkte plus das erzwungene "no action" aus force_actions.conf zu
# bekommen - also garantierte Zustellung in die Inbox.
# 2. Sie hat echten Spam an weitergeleitete Adressen durchgereicht, weil der
# email-worker seine Weiterleitungen mit Envelope-From = Empfaengeradresse
# (also eigene Domain) einliefert.
# 3. Sie hat nur ein Symptom kompensiert: die falschen Auth-/Hostname-Scores,
# die entstehen, weil alle Mail aus dem Worker-Container kommt. Das wird
# jetzt an der Ursache geloest - siehe rspamd/append.d/settings.conf.
#
# Die Datei bleibt (leer) bestehen, damit user-patches.sh sie beim Start ueber
# eine evtl. noch im Container liegende alte Version kopiert.

View File

@@ -1,6 +1,10 @@
rules { # documentation: https://rspamd.com/doc/modules/force_actions.html
DOCKER_WHITELIST_FORCE { #
action = "no action"; # Die Regel DOCKER_WHITELIST_FORCE ("no action" sobald DOCKER_WHITELIST greift)
expression = "DOCKER_WHITELIST"; # ist entfernt. Sie hat die Spam-Entscheidung fuer jede Mail mit eigener
} # Absenderdomain hart ueberschrieben - inklusive echtem Spam an weitergeleitete
} # Adressen. Das dazugehoerige Symbol existiert nicht mehr, siehe
# rspamd/local.d/multimap.conf.
#
# Keine rules{}-Sektion = force_actions ist wirkungslos. Datei bleibt bestehen,
# damit DMS sie beim Start ueber eine alte Version im Container kopiert.

View File

@@ -0,0 +1,179 @@
#!/bin/bash
# ===========================================================================
# rspamd_report.sh - lesbare Auswertung von /var/log/mail/rspamd.log
#
# Die Logzeilen von rspamd enthalten alles, sind aber unlesbar. Dieses Skript
# macht daraus zwei Ansichten:
#
# Uebersicht: eine Zeile je Mail mit Score, Aktion, Scanzeit, Von, An
# Detail: die Symbolaufschluesselung einer Mail, nach Gewicht sortiert,
# mit Summe - also warum genau diese Mail diesen Score hat
#
# AUSFUEHRUNG - immer IM Container:
#
# docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh
# docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh -n 40
# docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh --detail
# docker exec mailserver bash /tmp/docker-mailserver/rspamd_report.sh --detail C0D8A320066
#
# Optionen:
# -n N Anzahl Mails in der Uebersicht (Default 15)
# --detail [ID] Symbolaufschluesselung. ID ist ein beliebiges Fragment aus
# Queue-ID oder Message-ID. Ohne ID die letzte Mail.
# --slow MS Nur Mails, deren Scan laenger als MS Millisekunden dauerte
# --log PFAD Anderes Logfile (Default /var/log/mail/rspamd.log)
#
# Hinweis: Das Log enthaelt nur Mails, die seit dem Umstellen auf
# level = "info" (rspamd/local.d/logging.inc) verarbeitet wurden.
# ===========================================================================
set -uo pipefail
LOGFILE="/var/log/mail/rspamd.log"
COUNT=15
DETAIL=0
DETAIL_ID=""
SLOW_MS=0
usage() { sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' ; }
while [[ $# -gt 0 ]]; do
case "$1" in
-n) COUNT="${2:-15}"; shift 2 ;;
--detail) DETAIL=1
if [[ ${2:-} != "" && ${2:-} != -* ]]; then DETAIL_ID="$2"; shift 2; else shift; fi ;;
--slow) SLOW_MS="${2:-0}"; shift 2 ;;
--log) LOGFILE="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
esac
done
if [[ ! -r ${LOGFILE} ]]; then
echo "FEHLER: ${LOGFILE} nicht lesbar. Laeuft das Skript wirklich IM Container?" >&2
exit 1
fi
TASKS=$(grep 'rspamd_task_write_log' "${LOGFILE}" 2>/dev/null)
if [[ -z ${TASKS} ]]; then
echo "Keine ausgewerteten Mails im Log gefunden."
echo "Steht in rspamd/local.d/logging.inc wirklich level = \"info\"?"
exit 0
fi
# ---------------------------------------------------------------------------
# Detailansicht einer einzelnen Mail
# ---------------------------------------------------------------------------
if (( DETAIL )); then
if [[ -n ${DETAIL_ID} ]]; then
LINE=$(grep -F "${DETAIL_ID}" <<<"${TASKS}" | tail -1)
[[ -z ${LINE} ]] && { echo "Keine Mail mit '${DETAIL_ID}' im Log gefunden." >&2; exit 1; }
else
LINE=$(tail -1 <<<"${TASKS}")
fi
awk '
{
# Kopfdaten
ts = substr($0, 1, 19)
match($0, /qid: <[^>]*>/); qid = extract()
match($0, /from: <[^>]*>/); from = extract()
match($0, /rcpts: <[^>]*>/); rcpt = extract()
match($0, /settings_id: [^,]*/); sid = substr($0, RSTART+13, RLENGTH-13)
match($0, /time: [0-9.]+ms/); tm = substr($0, RSTART+6, RLENGTH-8)
match($0, /dns req: [0-9]+/); dns = substr($0, RSTART+9, RLENGTH-9)
# Aktion und Score: (default: F (no action): [4.74/500.00]
if (match($0, /\([a-z ]+\): \[-?[0-9.]+\//)) {
chunk = substr($0, RSTART, RLENGTH)
action = substr(chunk, 2, index(chunk, ")") - 2)
score = substr(chunk, index(chunk, "[") + 1, length(chunk) - index(chunk, "[") - 1)
}
printf "Zeit: %s\n", ts
printf "Queue-ID: %s\n", qid
printf "Von: %s\n", from
printf "An: %s\n", rcpt
printf "Regel: %s\n", sid
printf "Aktion: %s Score: %s Scanzeit: %s ms DNS: %s\n\n", action, score, tm, dns
# Symbolblock zwischen "] [" und "]), len:"
i = index($0, "] [")
if (i == 0) { print " (keine Symbole im Log)"; exit }
rest = substr($0, i + 3)
j = index(rest, "]), len:")
if (j == 0) j = length(rest)
syms = substr(rest, 1, j - 1)
# auf Kommas der obersten Ebene splitten (Klammern koennen Kommas enthalten)
depth = 0; cur = ""; n = 0
for (k = 1; k <= length(syms); k++) {
c = substr(syms, k, 1)
if (c == "{") depth++
else if (c == "}") depth--
if (c == "," && depth == 0) { if (cur != "") entries[++n] = cur; cur = ""; continue }
cur = cur c
}
if (cur != "") entries[++n] = cur
printf " %8s %-38s %s\n", "GEWICHT", "SYMBOL", "DETAIL"
printf " %8s %-38s %s\n", "-------", "--------------------------------------", "------"
total = 0
# zwei Durchlaeufe: erst die mit Gewicht, dann die neutralen
for (pass = 1; pass <= 2; pass++) {
for (e = 1; e <= n; e++) {
entry = entries[e]
p1 = index(entry, "(")
if (p1 == 0) continue
name = substr(entry, 1, p1 - 1)
p2 = index(entry, ")")
w = substr(entry, p1 + 1, p2 - p1 - 1)
payload = ""
b1 = index(entry, "{")
if (b1 > 0) payload = substr(entry, b1 + 1, length(entry) - b1 - 1)
gsub(/;$/, "", payload)
if (pass == 1 && w + 0 == 0) continue
if (pass == 2 && w + 0 != 0) continue
if (pass == 1) total += w
printf " %8.2f %-38s %s\n", w, name, substr(payload, 1, 60)
}
if (pass == 1) printf " %8s %s\n", "-------", ""
if (pass == 1) printf " %8.2f %s\n\n neutrale Symbole (Gewicht 0):\n", total, "SUMME"
}
}
function extract( s) { s = substr($0, RSTART, RLENGTH); return substr(s, index(s, "<") + 1, length(s) - index(s, "<") - 1) }
' <<<"${LINE}"
exit 0
fi
# ---------------------------------------------------------------------------
# Uebersicht
# ---------------------------------------------------------------------------
printf '%-19s %7s %-12s %7s %-30s %-30s\n' "ZEIT" "SCORE" "AKTION" "MS" "VON" "AN"
printf -- '--------------------------------------------------------------------------------------------------------------------\n'
awk -v slow="${SLOW_MS}" '
{
ts = substr($0, 1, 19)
match($0, /from: <[^>]*>/); from = extract()
match($0, /rcpts: <[^>]*>/); rcpt = extract()
match($0, /time: [0-9.]+ms/); tm = substr($0, RSTART+6, RLENGTH-8) + 0
if (match($0, /\([a-z ]+\): \[-?[0-9.]+\//)) {
chunk = substr($0, RSTART, RLENGTH)
action = substr(chunk, 2, index(chunk, ")") - 2)
score = substr(chunk, index(chunk, "[") + 1, length(chunk) - index(chunk, "[") - 1) + 0
} else { action = "?"; score = 0 }
if (tm < slow) next
printf "%-19s %7.2f %-12s %7.0f %-30.30s %-30.30s\n", ts, score, action, tm, from, rcpt
}
function extract( s) { s = substr($0, RSTART, RLENGTH); return substr(s, index(s, "<") + 1, length(s) - index(s, "<") - 1) }
' <<<"${TASKS}" | tail -n "${COUNT}"
echo
echo "Detail zu einer Mail: $0 --detail <Queue-ID>"
echo "Nur langsame Scans: $0 --slow 1000"

View File

@@ -0,0 +1,222 @@
#!/bin/bash
# ===========================================================================
# train_bayes.sh - Bayes-Klassifikator von rspamd aus den vorhandenen
# Postfaechern anlernen.
#
# Hintergrund: rspamd traegt mit BAYES_* erst dann zum Score bei, wenn genug
# gelernt wurde (Default: 200 Spam UND 200 Ham). Bis dahin liefert der
# Klassifikator gar nichts - genau das war im Log zu sehen, dort tauchte kein
# einziges BAYES_-Symbol auf. Statt monatelang auf manuelles Verschieben in
# den Junk-Ordner zu warten (das macht RSPAMD_LEARN=1 bereits automatisch),
# lernt dieses Skript einmalig aus dem, was ohnehin auf der Platte liegt.
#
# AUSFUEHRUNG - immer IM Container:
#
# docker exec mailserver bash /tmp/docker-mailserver/train_bayes.sh
# -> Trockenlauf: zaehlt nur, lernt nichts. Das ist der Default.
#
# docker exec mailserver bash /tmp/docker-mailserver/train_bayes.sh --apply
# -> lernt tatsaechlich
#
# Optionen:
# --apply Wirklich lernen (ohne das passiert nichts)
# --max N Max. Nachrichten je Postfach und Klasse (Default 400)
# --include-sent Gesendete Mails zusaetzlich als Ham lernen
# --only MUSTER Nur Postfaecher, deren Adresse eines der Muster
# enthaelt. Mehrfach angebbar oder kommasepariert:
# --only support@,andreas.knuth@
# WICHTIG: Ohne diese Option laeuft das Skript ueber
# ALLE Postfaecher aller Domains unter /var/mail.
# --host HOST:PORT rspamd-Controller (Default 127.0.0.1:11334)
#
# Passwort: Falls der Controller eines verlangt (bei euch in
# rspamd/override.d/worker-controller.inc gesetzt), vorweg setzen:
# docker exec -e RSPAMC_PASSWORD='...' mailserver bash /tmp/.../train_bayes.sh
#
# WICHTIG zur Datenqualitaet:
# - Ham kommt aus der INBOX. Liegt dort unerkannter Spam, wird er als Ham
# gelernt und verschlechtert die Erkennung. Vorher grob durchsehen lohnt.
# - Spam kommt aus Junk. Liegt dort faelschlich einsortierte legitime Mail,
# gilt dasselbe umgekehrt.
# - Ein sehr schiefes Verhaeltnis (z.B. 8000 Ham zu 50 Spam) macht den
# Klassifikator einseitig. Der Trockenlauf zeigt das Verhaeltnis an -
# danach ggf. mit --max nachsteuern.
# ===========================================================================
set -uo pipefail
MAILBASE="${MAILBASE:-/var/mail}"
RSPAMC_HOST="127.0.0.1:11334"
MAX_PER_CLASS=400
MAX_BYTES=5242880 # 5 MB - groessere Mails bringen fuers Lernen nichts
BATCH=50 # Dateien pro rspamc-Aufruf
APPLY=0
INCLUDE_SENT=0
ONLY_PATTERNS=()
usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ; }
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1; shift ;;
--max) MAX_PER_CLASS="${2:-}"; shift 2 ;;
--include-sent) INCLUDE_SENT=1; shift ;;
--only) IFS=',' read -ra _pat <<<"${2:-}"
ONLY_PATTERNS+=("${_pat[@]}"); shift 2 ;;
--host) RSPAMC_HOST="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; echo "Hilfe: $0 --help" >&2; exit 1 ;;
esac
done
if [[ ! -d ${MAILBASE} ]]; then
echo "FEHLER: ${MAILBASE} existiert nicht. Laeuft das Skript wirklich IM Container?" >&2
exit 1
fi
RSPAMC=(rspamc -h "${RSPAMC_HOST}")
[[ -n ${RSPAMC_PASSWORD:-} ]] && RSPAMC+=(-P "${RSPAMC_PASSWORD}")
if ! command -v rspamc >/dev/null 2>&1; then
echo "FEHLER: rspamc nicht gefunden." >&2
exit 1
fi
if ! "${RSPAMC[@]}" stat >/dev/null 2>&1; then
echo "FEHLER: Controller unter ${RSPAMC_HOST} nicht erreichbar oder Passwort noetig." >&2
echo " Versuche es mit: docker exec -e RSPAMC_PASSWORD='...' mailserver bash $0 ..." >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Dateien einsammeln: bis LIMIT regulaere Dateien unterhalb der uebergebenen
# Maildir-Verzeichnisse, groessenbegrenzt. Neueste zuerst waere schoener, ist
# aber bei zehntausenden Dateien zu teuer - Maildir-Namen beginnen mit dem
# Timestamp, daher liefert die Sortierung eine brauchbare Naeherung.
# ---------------------------------------------------------------------------
gather() {
local limit="$1"; shift
local dir
for dir in "$@"; do
[[ -d ${dir} ]] || continue
find "${dir}" -maxdepth 1 -type f -size -"${MAX_BYTES}"c -print 2>/dev/null
done | sort -r | head -n "${limit}"
}
# ---------------------------------------------------------------------------
# Lernen in Batches. Gibt "gelernt fehlgeschlagen" zurueck.
# ---------------------------------------------------------------------------
learn() {
local cmd="$1"; shift
local -a files=("$@")
local total=${#files[@]}
local ok=0 fail=0 idx=0 out
while (( idx < total )); do
out=$("${RSPAMC[@]}" "${cmd}" "${files[@]:idx:BATCH}" 2>&1)
ok=$(( ok + $(grep -c 'success = true' <<<"${out}") ))
fail=$(( fail + $(grep -ci 'error' <<<"${out}") ))
idx=$(( idx + BATCH ))
done
echo "${ok} ${fail}"
}
# ---------------------------------------------------------------------------
# Hauptlauf
# ---------------------------------------------------------------------------
if (( APPLY )); then
echo "=== Bayes-Training (SCHREIBEND) ==="
else
echo "=== Bayes-Training (Trockenlauf - es wird nichts gelernt) ==="
fi
echo "Basis: ${MAILBASE} | max ${MAX_PER_CLASS} je Postfach und Klasse"
if (( ${#ONLY_PATTERNS[@]} > 0 )); then
echo "Filter: nur Postfaecher mit '${ONLY_PATTERNS[*]}'"
else
echo "Filter: KEINER - es werden ALLE Postfaecher unter ${MAILBASE} erfasst"
fi
printf '\n%-42s %8s %8s\n' "POSTFACH" "SPAM" "HAM"
printf -- '---------------------------------------------------------------\n'
total_spam=0
total_ham=0
learned_spam=0
learned_ham=0
failed=0
for domain_dir in "${MAILBASE}"/*/; do
[[ -d ${domain_dir} ]] || continue
domain=$(basename "${domain_dir}")
for user_dir in "${domain_dir}"*/; do
user_dir="${user_dir%/}"
[[ -d ${user_dir}/cur ]] || continue # keine Maildir -> ueberspringen
mailbox="$(basename "${user_dir}")@${domain}"
if (( ${#ONLY_PATTERNS[@]} > 0 )); then
matched=0
for pat in "${ONLY_PATTERNS[@]}"; do
[[ ${mailbox} == *"${pat}"* ]] && { matched=1; break; }
done
(( matched )) || continue
fi
mapfile -t spam_files < <(gather "${MAX_PER_CLASS}" \
"${user_dir}/.Junk/cur" "${user_dir}/.Junk/new" \
"${user_dir}/.Spam/cur" "${user_dir}/.Spam/new")
ham_dirs=("${user_dir}/cur" "${user_dir}/new")
if (( INCLUDE_SENT )); then
ham_dirs+=("${user_dir}/.Sent/cur" "${user_dir}/.Sent Messages/cur")
fi
mapfile -t ham_files < <(gather "${MAX_PER_CLASS}" "${ham_dirs[@]}")
n_spam=${#spam_files[@]}
n_ham=${#ham_files[@]}
(( n_spam == 0 && n_ham == 0 )) && continue
printf '%-42s %8d %8d\n' "${mailbox}" "${n_spam}" "${n_ham}"
total_spam=$(( total_spam + n_spam ))
total_ham=$(( total_ham + n_ham ))
if (( APPLY )); then
if (( n_spam > 0 )); then
read -r ok fail <<<"$(learn learn_spam "${spam_files[@]}")"
learned_spam=$(( learned_spam + ok )); failed=$(( failed + fail ))
fi
if (( n_ham > 0 )); then
read -r ok fail <<<"$(learn learn_ham "${ham_files[@]}")"
learned_ham=$(( learned_ham + ok )); failed=$(( failed + fail ))
fi
fi
done
done
printf -- '---------------------------------------------------------------\n'
printf '%-42s %8d %8d\n\n' "SUMME" "${total_spam}" "${total_ham}"
if (( total_spam > 0 )); then
ratio=$(( total_ham / (total_spam > 0 ? total_spam : 1) ))
if (( ratio >= 5 )); then
echo "WARNUNG: Ham/Spam-Verhaeltnis etwa ${ratio}:1 - sehr einseitig."
echo " Besser mit kleinerem --max fahren, z.B. --max $(( total_spam / 4 + 10 ))."
echo
fi
fi
if (( ! APPLY )); then
echo "Trockenlauf beendet. Zum echten Lernen dasselbe Kommando mit --apply."
echo "rspamd braucht je Klasse mindestens 200 Nachrichten, bevor BAYES_* feuert."
exit 0
fi
echo "Gelernt: ${learned_spam} Spam, ${learned_ham} Ham, ${failed} Fehler/Duplikate."
echo
echo "=== rspamc stat ==="
"${RSPAMC[@]}" stat 2>/dev/null | grep -iE 'learn|classifier|statfile' || true
echo
echo "Kontrolle an der naechsten eingehenden Mail:"
echo " docker exec mailserver sh -c 'grep rspamd_task_write_log /var/log/mail/rspamd.log | tail -3'"
echo "In der Symbolliste sollte jetzt BAYES_HAM oder BAYES_SPAM auftauchen."

View File

@@ -1,47 +1,61 @@
#!/bin/bash #!/bin/bash
# user-patches.sh laeuft bei jedem Start von DMS automatisch # user-patches.sh laeuft bei jedem Start von DMS automatisch,
# nach dem Setup und bevor die Daemons gestartet werden.
ACCOUNTS_FILE="/tmp/docker-mailserver/postfix-accounts.cf" SRC_BASE="/tmp/docker-mailserver/rspamd"
WHITELIST_FILE="/etc/rspamd/override.d/docker_whitelist.map" LOCAL_D="/etc/rspamd/local.d"
# --- Rspamd Whitelist generieren --- # ---------------------------------------------------------------------------
STATIC_DOMAINS=( # 1) local.d configs kopieren
"bayarea-cc.com" # DMS kopiert nur rspamd/override.d automatisch, local.d nicht.
"ruehrgedoens.de" # ---------------------------------------------------------------------------
"annavillesda.org"
"bizmatch.net"
"biz-match.com"
"qrmaster.net"
"nqsltd.com"
"iitwelders.com"
)
echo "Patching: Generiere Rspamd Whitelist aus Accounts + statischen Domains..."
{
for domain in "${STATIC_DOMAINS[@]}"; do
echo "$domain"
done
if [ -f "$ACCOUNTS_FILE" ]; then
awk -F'|' '{print $1}' "$ACCOUNTS_FILE" | cut -d'@' -f2
fi
} | sort | uniq > "$WHITELIST_FILE"
chmod 644 "$WHITELIST_FILE"
chown _rspamd:_rspamd "$WHITELIST_FILE" 2>/dev/null || true
echo "Whitelist erstellt:"
cat "$WHITELIST_FILE"
# --- local.d configs manuell kopieren (DMS kopiert local.d nicht automatisch) ---
echo "Patching: Kopiere custom rspamd local.d configs..." echo "Patching: Kopiere custom rspamd local.d configs..."
SRC="/tmp/docker-mailserver/rspamd/local.d" if [ -d "${SRC_BASE}/local.d" ]; then
DST="/etc/rspamd/local.d" for f in "${SRC_BASE}/local.d"/*; do
if [ -d "$SRC" ]; then
for f in "$SRC"/*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
cp "$f" "$DST/$(basename "$f")" base=$(basename "$f")
chown root:root "$DST/$(basename "$f")" cp "$f" "${LOCAL_D}/${base}"
chmod 644 "$DST/$(basename "$f")" chown root:root "${LOCAL_D}/${base}"
echo " Kopiert: $(basename "$f") -> $DST/" chmod 644 "${LOCAL_D}/${base}"
echo " Kopiert: ${base} -> ${LOCAL_D}/"
done done
fi fi
# ---------------------------------------------------------------------------
# 2) append.d: an bestehende local.d-Dateien ANHAENGEN statt sie zu ersetzen
#
# Zwingend fuer settings.conf: DMS legt dort selbst die Regel fuer
# authentifizierte User ab (DMS::SED_TAG::1). Ein cp darueber wuerde sie
# loeschen und RSPAMD_CHECK_AUTHENTICATED=0 wirkungslos machen.
#
# Der Marker DMS-CUSTOM-APPEND macht das Anhaengen idempotent - /etc/rspamd
# liegt im Container-Dateisystem und ueberlebt einen `docker restart`.
# ---------------------------------------------------------------------------
echo "Patching: Haenge custom rspamd Snippets an local.d configs an..."
if [ -d "${SRC_BASE}/append.d" ]; then
for f in "${SRC_BASE}/append.d"/*; do
[ -f "$f" ] || continue
base=$(basename "$f")
target="${LOCAL_D}/${base}"
if grep -q 'DMS-CUSTOM-APPEND' "$target" 2>/dev/null; then
echo " Bereits vorhanden, uebersprungen: ${base}"
continue
fi
printf '\n' >>"$target"
cat "$f" >>"$target"
chown root:root "$target"
chmod 644 "$target"
echo " Angehaengt: ${base} -> ${target}"
done
fi
# ---------------------------------------------------------------------------
# HINWEIS: Die frueher hier erzeugte Rspamd-Whitelist
# (/etc/rspamd/override.d/docker_whitelist.map aus postfix-accounts.cf +
# statischer Domainliste) ist entfernt. Die zugehoerige multimap-Regel
# DOCKER_WL (-50) und das erzwungene "no action" aus force_actions.conf gibt
# es nicht mehr - sie waren ein Spoofing-Loch und haben echten Spam an
# weitergeleitete Adressen durchgereicht. Ersatz: rspamd/append.d/settings.conf.
# ---------------------------------------------------------------------------

View File

@@ -32,13 +32,27 @@ class email_config extends rcube_plugin
function email_config_form() function email_config_form()
{ {
$rcmail = rcube::get_instance(); $rcmail = rcube::get_instance();
$email = $rcmail->user->get_username(); $email = $rcmail->user->get_username();
$secret_key = 'SHARED_SECRET_KEY_987654321'; $secret_key = 'SHARED_SECRET_KEY_987654321';
$config_url = 'https://config.email-bayarea.com';
$expires = time() + 3600; // mailadmin.<domain der adresse>, z.B. support@bayarea-cc.com
$data = $email . '|' . $expires; // -> https://mailadmin.bayarea-cc.com
$at = strrpos($email, '@');
if ($at === false) {
// Roundcube liefert ausnahmsweise keinen vollen Namen: hier kann kein
// sinnvoller Link gebaut werden.
return '';
}
$domain = strtolower(substr($email, $at + 1));
$config_url = 'https://mailadmin.' . $domain;
$expires = time() + 3600;
$data = $email . '|' . $expires;
$signature = hash_hmac('sha256', $data, $secret_key); $signature = hash_hmac('sha256', $data, $secret_key);
$url = $config_url . '/?email=' . urlencode($email) . '&expires=' . $expires . '&signature=' . $signature; $url = $config_url . '/?email=' . urlencode($email)
. '&expires=' . $expires
. '&signature=' . $signature;
$out = ' $out = '
<div class="box" style="max-width: 600px; margin: 40px auto; padding: 30px; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <div class="box" style="max-width: 600px; margin: 40px auto; padding: 30px; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">