diff --git a/config/config.go b/config/config.go
index 7320de3..33012b8 100644
--- a/config/config.go
+++ b/config/config.go
@@ -184,6 +184,7 @@ type SMTPConfig struct {
Domain string `yaml:"domain"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
+ RequireTLS bool `yaml:"require_tls"` // PROJ-71: reject DATA over plaintext when true (needs tls_cert/tls_key set)
MaxSizeMB int `yaml:"max_size_mb"`
AllowedIPs []string `yaml:"allowed_ips"`
TenantRouting string `yaml:"tenant_routing"` // "domain" or "default"
diff --git a/features/INDEX.md b/features/INDEX.md
index 1acb1ad..206ee50 100644
--- a/features/INDEX.md
+++ b/features/INDEX.md
@@ -86,7 +86,8 @@
| PROJ-68 | sudo-Provisionierung für Admin-Dienststeuerung fehlte komplett | Deployed | [PROJ-68](PROJ-68-sudo-provisionierung-dienststeuerung.md) | 2026-07-05 |
| PROJ-69 | Admin-Dashboard Tab-Gruppierung (2-Ebenen-Navigation) | Deployed | [PROJ-69](PROJ-69-admin-tabs-gruppierung.md) | 2026-07-06 |
| PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | Deployed | [PROJ-70](PROJ-70-imap-rueckholung-self-service.md) | 2026-07-07 |
+| PROJ-71 | TLS-Pflicht (optional) für eingehenden SMTP-BCC-Journaling-Kanal | Deployed | [PROJ-71](PROJ-71-smtp-require-tls.md) | 2026-07-08 |
-## Next Available ID: PROJ-71
+## Next Available ID: PROJ-72
diff --git a/features/PROJ-71-smtp-require-tls.md b/features/PROJ-71-smtp-require-tls.md
new file mode 100644
index 0000000..6a8403a
--- /dev/null
+++ b/features/PROJ-71-smtp-require-tls.md
@@ -0,0 +1,49 @@
+---
+id: PROJ-71
+title: TLS-Pflicht (optional) für eingehenden SMTP-BCC-Journaling-Kanal
+status: Deployed
+created: 2026-07-08
+---
+
+## Problem
+
+Eingehender SMTP-Daemon (`internal/smtpd/`) für BCC-Journaling erlaubt STARTTLS
+optional, erzwingt es aber nicht. Mails können unverschlüsselt per Klartext
+eintreffen, obwohl TLS-Zertifikat konfiguriert ist. Sicherheitslücke für den
+Journaling-Kanal, der Original-Mails 1:1 (Rohbyte-Erhalt für Revisionssicherheit,
+siehe GoBD-Anforderung) entgegennimmt.
+
+## Lösung
+
+Neues optionales Config-Flag `smtp.require_tls` (`config/config.go`, Feld
+`RequireTLS`). Wenn `true`:
+
+- Start-Validierung: Daemon startet nicht, wenn `require_tls: true` gesetzt ist,
+ aber `tls_cert`/`tls_key` fehlen (`internal/smtpd/smtpd.go` `Start()`).
+- Enforcement bei `MAIL FROM`: Session ohne aktive TLS-Verbindung wird mit
+ `530 5.7.0 Must issue STARTTLS first` abgelehnt, bevor Daten übertragen werden
+ (`session.Mail()`).
+- TLS-Status pro Session wird beim Verbindungsaufbau via
+ `c.TLSConnectionState()` ermittelt (`backend.NewSession()`).
+
+Standardmäßig deaktiviert (`require_tls` fehlt/false) — keine Breaking Change
+für bestehende Installationen ohne TLS-Zertifikat.
+
+## Implementation Notes
+
+- `config/config.go`: `SMTPConfig.RequireTLS bool` `yaml:"require_tls"`.
+- `internal/smtpd/smtpd.go`:
+ - `session.isTLS bool` Feld, gesetzt in `NewSession()`.
+ - `Start()`: Fehler beim Boot, falls `RequireTLS` ohne Cert/Key.
+ - `session.Mail()`: Reject vor Datenübertragung, spart Bandbreite ggü.
+ Ablehnung erst nach `DATA`.
+- Rohbyte-Erhalt, Message-ID-Erhalt, Envelope/Header-Trennung (BCC-Journaling)
+ bereits vorhanden und unverändert (siehe Code-Review vom 2026-07-08) — dieses
+ Feature schließt nur die TLS-Lücke im Transportkanal.
+
+## Acceptance Criteria
+
+- [x] `require_tls: false`/fehlend → Verhalten unverändert (STARTTLS optional).
+- [x] `require_tls: true` ohne `tls_cert`/`tls_key` → Daemon-Start schlägt fehl.
+- [x] `require_tls: true` + Cert/Key gesetzt → Klartext-`MAIL FROM` wird mit
+ 530 abgelehnt, STARTTLS-Verbindung wird angenommen.
diff --git a/internal/smtpd/smtpd.go b/internal/smtpd/smtpd.go
index 8b8073f..9c438d8 100644
--- a/internal/smtpd/smtpd.go
+++ b/internal/smtpd/smtpd.go
@@ -225,6 +225,8 @@ func (d *Daemon) Start() error {
return fmt.Errorf("smtpd: load TLS cert: %w", err)
}
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
+ } else if d.cfg.RequireTLS {
+ return fmt.Errorf("smtpd: require_tls is set but tls_cert/tls_key are missing")
}
d.mu.Lock()
@@ -322,16 +324,20 @@ func (b *backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
}
}
- b.daemon.logger.Debug("SMTP: new session", "ip", remoteIP)
+ _, isTLS := c.TLSConnectionState()
+
+ b.daemon.logger.Debug("SMTP: new session", "ip", remoteIP, "tls", isTLS)
return &session{
daemon: b.daemon,
remoteIP: remoteIP,
+ isTLS: isTLS,
}, nil
}
type session struct {
daemon *Daemon
remoteIP string
+ isTLS bool
from string
rcpts []string
}
@@ -342,6 +348,14 @@ func (s *session) AuthPlain(_, _ string) error {
}
func (s *session) Mail(from string, _ *smtp.MailOptions) error {
+ if s.daemon.cfg.RequireTLS && !s.isTLS {
+ s.daemon.stats.Rejected.Add(1)
+ return &smtp.SMTPError{
+ Code: 530,
+ EnhancedCode: smtp.EnhancedCode{5, 7, 0},
+ Message: "Must issue STARTTLS first",
+ }
+ }
s.from = from
return nil
}
diff --git a/src/components/admin/tabs/AuditTab.tsx b/src/components/admin/tabs/AuditTab.tsx
index 6bc8739..a18d358 100644
--- a/src/components/admin/tabs/AuditTab.tsx
+++ b/src/components/admin/tabs/AuditTab.tsx
@@ -13,6 +13,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
const AUDIT_PAGE_SIZE = 25;
@@ -75,8 +81,19 @@ export function AuditTab({
{entry.username}
-
- {entry.detail}
+
+
+
+
+
+ {entry.detail}
+
+
+
+ {entry.detail}
+
+
+
))}