feat(PROJ-21): Phase 2+3+5+8 Multi-Tenancy + PROJ-2 EML/MBOX Upload

Phase 2a: userstore domain_admin/superadmin Rollen, User.TenantID,
          ListByTenant, UpsertLDAPUser mit tenantID
Phase 2b: storage.Save() mit tenantID *int64, email_refs Tabelle,
          GetTenantForMail, GetAllIDsByTenant, StatsByTenant
Phase 2c: JWT-Claims tenant_id/tenant_slug, Session.TenantID,
          Login Domain-Erkennung via E-Mail-Domain
Phase 3:  tenantMiddleware, Handler-Filterung (Users, Mail, Stats)
Phase 5:  SMTP Domain-Routing via DomainToTenantFunc Callback,
          config smtp.tenant_routing + default_tenant_id
Phase 8:  archivmail migrate-tenants Subkommando
PROJ-2:   Upload-Seite /admin/upload mit DropZone + Progress-Polling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 21:03:40 +01:00
parent 5250ffcd52
commit 479c27e5a8
16 changed files with 966 additions and 158 deletions
+52 -9
View File
@@ -5,6 +5,7 @@ package smtpd
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
@@ -22,6 +23,10 @@ import (
"github.com/archivmail/internal/storage"
)
// DomainToTenantFunc resolves an e-mail domain to a tenant ID.
// Returns nil if no tenant matches the domain.
type DomainToTenantFunc func(ctx context.Context, domain string) (*int64, error)
// Stats holds runtime statistics for the SMTP daemon.
type Stats struct {
Received atomic.Int64 // total emails successfully stored
@@ -35,14 +40,16 @@ type IndexCallback func(raw []byte, id string)
// Daemon is the embedded receive-only SMTP server.
type Daemon struct {
cfg config.SMTPConfig
store *storage.Store
logger *slog.Logger
stats Stats
server *smtp.Server
mu sync.Mutex
running bool
indexCallback IndexCallback
cfg config.SMTPConfig
store *storage.Store
logger *slog.Logger
stats Stats
server *smtp.Server
mu sync.Mutex
running bool
indexCallback IndexCallback
domainToTenant DomainToTenantFunc // optional domain→tenant routing
defaultTenantID *int64 // fallback tenant if no domain matches
}
// New creates a new SMTP Daemon. Call Start() to begin accepting connections.
@@ -56,6 +63,39 @@ func New(cfg config.SMTPConfig, store *storage.Store, logger *slog.Logger) *Daem
return d
}
// SetDomainToTenant wires in the domain→tenant resolution function.
func (d *Daemon) SetDomainToTenant(fn DomainToTenantFunc, defaultTenantID *int64) {
d.domainToTenant = fn
d.defaultTenantID = defaultTenantID
}
// resolveTenantFromRcpts extracts the domain from RCPT TO addresses and
// resolves it to a tenant ID via the configured DomainToTenantFunc.
func (d *Daemon) resolveTenantFromRcpts(rcpts []string) *int64 {
if d.domainToTenant == nil {
return d.defaultTenantID
}
ctx := context.Background()
for _, rcpt := range rcpts {
// Strip angle brackets if present
addr := strings.Trim(rcpt, "<>")
at := strings.LastIndex(addr, "@")
if at < 0 {
continue
}
domain := strings.ToLower(addr[at+1:])
tenantID, err := d.domainToTenant(ctx, domain)
if err != nil {
d.logger.Warn("SMTP: tenant lookup failed", "domain", domain, "err", err)
continue
}
if tenantID != nil {
return tenantID
}
}
return d.defaultTenantID
}
// SetIndexCallback sets the function called after each successfully stored mail.
func (d *Daemon) SetIndexCallback(cb IndexCallback) {
d.indexCallback = cb
@@ -232,7 +272,10 @@ func (s *session) Data(r io.Reader) error {
}
raw := buf.Bytes()
id, err := s.daemon.store.Save(raw, time.Now())
// Determine tenant from RCPT TO domain routing
tenantID := s.daemon.resolveTenantFromRcpts(s.rcpts)
id, err := s.daemon.store.Save(context.Background(), raw, time.Now(), tenantID)
if err != nil {
s.daemon.stats.Rejected.Add(1)
s.daemon.logger.Error("SMTP: storage failed", "from", s.from, "err", err)