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
+2 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
@@ -220,7 +221,7 @@ func importMessage(mailStore *storage.Store, idx index.Indexer, raw []byte, dryR
return "imported"
}
id, err := mailStore.Save(raw, pm.Date)
id, err := mailStore.Save(context.Background(), raw, pm.Date, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: save failed: %v\n", err)
return "error"
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/archivmail/config"
"github.com/archivmail/internal/userstore"
)
// runMigrateTenants performs a one-time migration to set up the default tenant,
// assign all existing users/emails/audit entries to it, and create a superadmin user.
//
// Usage: archivmail migrate-tenants [-config /etc/archivmail/config.yml]
func runMigrateTenants(args []string) {
fs := flag.NewFlagSet("migrate-tenants", flag.ExitOnError)
configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file")
fs.Parse(args) //nolint
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: load config: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.Database.DSN())
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: db connect: %v\n", err)
os.Exit(1)
}
defer pool.Close()
// Check if migration has already run
var tenantCount int
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&tenantCount)
if tenantCount > 0 {
fmt.Println("migrate-tenants: tenants table already populated — skipping migration")
os.Exit(0)
}
// 1. Create default tenant
var defaultTenantID int64
err = pool.QueryRow(ctx,
`INSERT INTO tenants (name, slug, active) VALUES ('default', 'default', true) RETURNING id`,
).Scan(&defaultTenantID)
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: create default tenant: %v\n", err)
os.Exit(1)
}
fmt.Printf("migrate-tenants: created default tenant with id=%d\n", defaultTenantID)
// 2. Assign all users without tenant to the default tenant
tag, err := pool.Exec(ctx,
`UPDATE users SET tenant_id = $1 WHERE tenant_id IS NULL`, defaultTenantID)
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: update users: %v\n", err)
os.Exit(1)
}
fmt.Printf("migrate-tenants: assigned %d users to default tenant\n", tag.RowsAffected())
// 3. Assign all emails without tenant to the default tenant
tag, err = pool.Exec(ctx,
`UPDATE emails SET tenant_id = $1 WHERE tenant_id IS NULL`, defaultTenantID)
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: update emails: %v\n", err)
os.Exit(1)
}
fmt.Printf("migrate-tenants: assigned %d emails to default tenant\n", tag.RowsAffected())
// 4. Seed email_refs for existing emails (all assigned to default tenant)
_, err = pool.Exec(ctx, `
INSERT INTO email_refs (email_id, tenant_id)
SELECT id, $1 FROM emails WHERE tenant_id = $1
ON CONFLICT (email_id, tenant_id) DO NOTHING
`, defaultTenantID)
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: seed email_refs: %v\n", err)
os.Exit(1)
}
// 5. Assign audit_log entries to default tenant (best-effort — column may not exist yet)
_, _ = pool.Exec(ctx,
`UPDATE audit_log SET tenant_id = $1 WHERE tenant_id IS NULL`, defaultTenantID)
// 6. Promote existing 'admin' users to 'domain_admin'
tag, err = pool.Exec(ctx,
`UPDATE users SET role = $1 WHERE role = 'admin'`, userstore.RoleDomainAdmin)
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: promote admins: %v\n", err)
os.Exit(1)
}
fmt.Printf("migrate-tenants: promoted %d admin(s) to domain_admin\n", tag.RowsAffected())
// 7. Create superadmin user (tenant_id = NULL = global access)
superPw, err := randomPassword()
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: generate password: %v\n", err)
os.Exit(1)
}
// Use userstore so the password is properly bcrypt-hashed
users, err := userstore.New(cfg.Database.DSN())
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: userstore init: %v\n", err)
os.Exit(1)
}
defer users.Close()
_, err = users.Create(userstore.CreateUserRequest{
Username: "superadmin",
Email: "superadmin@archivmail.local",
Password: superPw,
Role: userstore.RoleSuperAdmin,
TenantID: nil, // global — no tenant restriction
})
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-tenants: create superadmin: %v\n", err)
os.Exit(1)
}
fmt.Println()
fmt.Println("╔══════════════════════════════════════════════════════════════════╗")
fmt.Println("║ ARCHIVMAIL — TENANT MIGRATION ABGESCHLOSSEN ║")
fmt.Println("║ Superadmin-Zugangsdaten (NUR EINMAL ANGEZEIGT): ║")
fmt.Printf( "║ superadmin : %-52s ║\n", superPw)
fmt.Println("║ Passwort sofort nach dem ersten Login ändern! ║")
fmt.Println("╚══════════════════════════════════════════════════════════════════╝")
fmt.Println()
fmt.Printf("migrate-tenants: done — default tenant id=%d\n", defaultTenantID)
}
+28 -9
View File
@@ -41,6 +41,9 @@ func main() {
case "export":
runExport(os.Args[2:])
return
case "migrate-tenants":
runMigrateTenants(os.Args[2:])
return
case "version":
fmt.Printf("archivmail %s\n", version)
return
@@ -151,6 +154,15 @@ func main() {
Handler: srv,
}
// Tenant store (Multi-Tenancy Phase 1+2) — must be initialised before SMTP daemon
tenantSt, err := tenantstore.New(cfg.Database.DSN())
if err != nil {
logger.Error("tenant store init failed", "err", err)
os.Exit(1)
}
defer tenantSt.Close()
srv.SetTenants(tenantSt)
// Start SMTP daemon with index worker integration
if cfg.SMTP.Bind == "" {
cfg.SMTP.Bind = fmt.Sprintf(":%d", cfg.Server.SMTPPort)
@@ -159,6 +171,22 @@ func main() {
smtpDaemon.SetIndexCallback(func(raw []byte, id string) {
submitToWorker(worker, mailStore, raw, id, logger)
})
// Wire tenant routing into SMTP daemon
if cfg.SMTP.TenantRouting == "domain" {
var defaultTenantID *int64
if cfg.SMTP.DefaultTenantID != 0 {
id := cfg.SMTP.DefaultTenantID
defaultTenantID = &id
}
smtpDaemon.SetDomainToTenant(func(ctx context.Context, domain string) (*int64, error) {
t, err := tenantSt.GetByDomain(ctx, domain)
if err != nil || t == nil {
return nil, err
}
id := t.ID
return &id, nil
}, defaultTenantID)
}
if err := smtpDaemon.Start(); err != nil {
logger.Error("SMTP daemon failed to start", "err", err)
os.Exit(1)
@@ -184,15 +212,6 @@ func main() {
defer imapSched.Stop()
srv.SetImap(imapSt, imapImp, imapSched)
// Tenant store (Multi-Tenancy Phase 1)
tenantSt, err := tenantstore.New(cfg.Database.DSN())
if err != nil {
logger.Error("tenant store init failed", "err", err)
os.Exit(1)
}
defer tenantSt.Close()
srv.SetTenants(tenantSt)
// POP3 store + importer
pop3St, err := pop3store.New(cfg.Database.DSN(), cfg.API.Secret)
if err != nil {