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:
+52
-9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user