feat(PROJ-46): E-Mail als primärer Login-Identifier für Tenant-User

Tenant-User (tenant_id IS NOT NULL) melden sich künftig per E-Mail an statt
per Username — behebt Verwechslungen wie im Support-Fall vom 2026-06-13
(Login schlug trotz Passwort-Reset fehl, weil E-Mail statt Username
verwendet wurde). Nicht-Tenant-User (Superadmin/System) können weiterhin
Username ODER E-Mail nutzen.

Neue Store.VerifyLogin() prüft erst per E-Mail (alle User), fällt dann auf
Username zurück (nur tenant_id IS NULL). VerifyPassword() bleibt für den
IMAP-Server-Login-Pfad (PROJ-26) unverändert. Bewusster Breaking Change für
Tenant-User, Datenqualität vorab geprüft (0 Kollisionen).

Security-Nachtrag: bcrypt-Dummy-Compare im "user not found"-Pfad ergänzt,
um Timing-basierte Identifier-Enumeration zu verhindern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-03 23:37:20 +02:00
co-authored by Claude Sonnet 5
parent 804cd62201
commit 767373b206
18 changed files with 1710 additions and 14 deletions
+39
View File
@@ -101,6 +101,13 @@ func (d *Daemon) resolveTenantFromRcpts(rcpts []string) *int64 {
// This handles BCC-journaling where RCPT TO is the archive's own address and
// the real sender/recipient domain is only visible in the RFC 2822 headers.
func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 {
// 0. PROJ-43: explicit pattern routing rules take precedence over the plain
// tenant_domains mapping, because they are more specific (wildcard
// domains, exact sender/recipient addresses, priority ordering).
if tid := d.resolveTenantByRules(rcpts, raw); tid != nil {
return tid
}
if d.domainToTenant == nil {
return d.defaultTenantID
}
@@ -144,6 +151,38 @@ func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 {
return d.defaultTenantID
}
// resolveTenantByRules gathers the From address and recipient list (envelope
// RCPT TO plus header To/Cc) and evaluates the PROJ-43 tenant_routing_rules.
// Returns nil when the store is unavailable or no rule matches.
func (d *Daemon) resolveTenantByRules(rcpts []string, raw []byte) *int64 {
if d.store == nil {
return nil
}
var from string
recipients := make([]string, 0, len(rcpts)+4)
for _, r := range rcpts {
recipients = append(recipients, strings.Trim(r, "<>"))
}
if msg, err := mail.ReadMessage(bytes.NewReader(raw)); err == nil {
if addrs, err := mail.ParseAddressList(msg.Header.Get("From")); err == nil && len(addrs) > 0 {
from = addrs[0].Address
}
for _, hdr := range []string{"To", "Cc"} {
if addrs, err := mail.ParseAddressList(msg.Header.Get(hdr)); err == nil {
for _, a := range addrs {
recipients = append(recipients, a.Address)
}
}
}
}
tid, err := d.store.ResolveTenantByRoutingRules(context.Background(), from, recipients)
if err != nil {
d.logger.Warn("SMTP: routing rule lookup failed", "err", err)
return nil
}
return tid
}
// SetIndexCallback sets the function called after each successfully stored mail.
func (d *Daemon) SetIndexCallback(cb IndexCallback) {
d.indexCallback = cb