package server import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" aiapi "github.com/ultisuite/ulti-backend/internal/api/ai" authapi "github.com/ultisuite/ulti-backend/internal/api/auth" "github.com/ultisuite/ulti-backend/internal/api/admin" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" "github.com/ultisuite/ulti-backend/internal/api/devices" "github.com/ultisuite/ulti-backend/internal/api/docs" "github.com/ultisuite/ulti-backend/internal/api/drive" mailapi "github.com/ultisuite/ulti-backend/internal/api/mail" migrationapi "github.com/ultisuite/ulti-backend/internal/api/migration" "github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge" "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" meetapi "github.com/ultisuite/ulti-backend/internal/api/meet" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/office" "github.com/ultisuite/ulti-backend/internal/api/richtext" "github.com/ultisuite/ulti-backend/internal/api/ultidraw" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" usersapi "github.com/ultisuite/ulti-backend/internal/api/users" "github.com/ultisuite/ulti-backend/internal/automation" "github.com/ultisuite/ulti-backend/internal/authentik" "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/contacts/discovery" "github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/filescan" "github.com/ultisuite/ulti-backend/internal/httpcors" mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" "github.com/ultisuite/ulti-backend/internal/mail/hosted" "github.com/ultisuite/ulti-backend/internal/mail/stalwart" "github.com/ultisuite/ulti-backend/internal/migration" "github.com/ultisuite/ulti-backend/internal/provision" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/smtp" mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/mail/webhooks" "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/observability" "github.com/ultisuite/ulti-backend/internal/orgpolicy" "github.com/ultisuite/ulti-backend/internal/photos" "github.com/ultisuite/ulti-backend/internal/push" "github.com/ultisuite/ulti-backend/internal/realtime" "github.com/ultisuite/ulti-backend/internal/search" "github.com/ultisuite/ulti-backend/internal/securityaudit" ) // App holds wired dependencies and the HTTP router. type App struct { Router chi.Router Pool *pgxpool.Pool Redis *redis.Client VerifierHolder *auth.Holder MailOAuth *mailoauth.Service ownsPool bool ownsRedis bool cancel context.CancelFunc } // New builds the application router and optional background workers. func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { if cfg == nil { return nil, fmt.Errorf("config is nil") } workerCtx, cancel := context.WithCancel(ctx) pool := opts.Pool ownsPool := false if pool == nil { var err error pool, err = pgxpool.New(workerCtx, cfg.DatabaseURL) if err != nil { cancel() return nil, fmt.Errorf("connect database: %w", err) } ownsPool = true } if !opts.SkipAuthentikProvisioner { authentik.StartProvisioner(workerCtx, pool, cfg) } rdb := opts.Redis ownsRedis := false if rdb == nil { rdb = redis.NewClient(&redis.Options{ Addr: cfg.KeyDBAddr, Password: cfg.KeyDBPassword, DB: cfg.KeyDBDB, }) ownsRedis = true } minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""), Secure: cfg.RustFSUseSSL, }) if err != nil { cancel() if ownsPool { pool.Close() } if ownsRedis { _ = rdb.Close() } return nil, fmt.Errorf("rustfs client: %w", err) } attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket) if err := attachmentStorage.EnsureBucket(workerCtx); err != nil { slog.Warn("mail attachments bucket check failed", "error", err) } verifierHolder := opts.VerifierHolder if verifierHolder == nil { verifier, verr := auth.NewVerifierWithRetry(workerCtx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second) if verr != nil { slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", verr) } verifierHolder = auth.NewHolder(verifier) if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain) verifierHolder = pending verifierHolder.StartBackgroundRetry(workerCtx, 5*time.Second) } } if cfg.IsProduction() { if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("missing required OIDC configuration in production") } if !verifierHolder.Ready() { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("OIDC verifier initialization failed in production") } if err := cfg.ValidateSecretRotation(); err != nil { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("secret rotation policy: %w", err) } } else if err := cfg.ValidateSecretRotation(); err != nil { slog.Warn("secret rotation policy warning", "error", err) } credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID) if err != nil { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("mail credential encryption: %w", err) } auditLogger := securityaudit.NewLogger(pool) var ncClient *nextcloud.Client if cfg.NextcloudEnabled { ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass). WithPublicURL(cfg.NextcloudPublicURL). WithDrivePublicURL(cfg.DrivePublicURL). WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager)) slog.Info("nextcloud enabled", "url", cfg.NextcloudURL, "public_url", cfg.NextcloudPublicURL, "drive_public_url", cfg.DrivePublicURL) } var meetCfg *meet.Config if cfg.JitsiEnabled { meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain) slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain) } var photosClient *photos.Client if cfg.ImmichEnabled { photosClient = photos.NewClient(cfg.ImmichAPIURL) slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL) } hub := realtime.NewHub(verifierHolder, pool) healthChecker := observability.NewHealthChecker(cfg, pool, rdb) pushDispatcher := push.NewDispatcher(pool, push.Config{ FCMServiceAccountJSON: cfg.FCMServiceAccountJSON, FCMProjectID: cfg.FCMProjectID, APNSPrivateKey: cfg.APNSPrivateKey, APNSKeyID: cfg.APNSKeyID, APNSTeamID: cfg.APNSTeamID, APNSBundleID: cfg.APNSBundleID, APNSProduction: cfg.APNSProduction, }) hookExec := webhooks.NewExecutor(pool) rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec) autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec) oauthRedirect := cfg.MailOAuthRedirectURL if oauthRedirect == "" { oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port) if cfg.Domain != "" && cfg.Domain != "localhost" { oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain) } } mailOAuthSvc := mailoauth.NewService(mailoauth.Config{ GoogleClientID: cfg.MailGoogleOAuthClientID, GoogleClientSecret: cfg.MailGoogleOAuthClientSecret, MicrosoftClientID: cfg.MailMicrosoftOAuthClientID, MicrosoftSecret: cfg.MailMicrosoftOAuthSecret, MicrosoftTenant: cfg.MailMicrosoftOAuthTenant, RedirectURL: oauthRedirect, }, rdb) stlwClient := stalwart.NewClient(stalwart.Config{ Enabled: cfg.StalwartEnabled, BaseURL: cfg.StalwartAPIURL, APIKey: cfg.StalwartAPIKey, IMAPHost: cfg.StalwartIMAPHost, IMAPPort: cfg.StalwartIMAPPort, IMAPTLS: cfg.StalwartIMAPTLS, SMTPHost: cfg.StalwartSMTPHost, SMTPPort: cfg.StalwartSMTPPort, SMTPTLS: cfg.StalwartSMTPTLS, }) hostedSvc := hosted.NewService(pool, stlwClient, credentialManager) if cfg.PlatformMailDomain != "" { if _, err := hostedSvc.EnsurePlatformDomain(workerCtx, cfg.PlatformMailDomain); err != nil { slog.Warn("platform mail domain bootstrap failed", "domain", cfg.PlatformMailDomain, "error", err) } } migrationOAuthRedirect := cfg.MigrationOAuthRedirectURL if migrationOAuthRedirect == "" { migrationOAuthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/migration/oauth/callback", cfg.Port) if cfg.Domain != "" && cfg.Domain != "localhost" { migrationOAuthRedirect = fmt.Sprintf("https://%s/api/v1/migration/oauth/callback", cfg.Domain) } } migrationOAuthSvc := migration.NewOAuthService(migration.OAuthConfig{ GoogleClientID: firstNonEmpty(cfg.MigrationGoogleOAuthClientID, cfg.MailGoogleOAuthClientID), GoogleClientSecret: firstNonEmpty(cfg.MigrationGoogleOAuthClientSecret, cfg.MailGoogleOAuthClientSecret), MicrosoftClientID: firstNonEmpty(cfg.MigrationMicrosoftOAuthClientID, cfg.MailMicrosoftOAuthClientID), MicrosoftSecret: firstNonEmpty(cfg.MigrationMicrosoftOAuthSecret, cfg.MailMicrosoftOAuthSecret), MicrosoftTenant: firstNonEmpty(cfg.MigrationMicrosoftOAuthTenant, cfg.MailMicrosoftOAuthTenant), RedirectURL: migrationOAuthRedirect, }, rdb) migrationSvc := migration.NewService(pool, rdb, credentialManager, hostedSvc, migrationOAuthSvc) migrationSvc.SetCutoverConfig(migration.CutoverConfig{ ExpectedMXHosts: migration.ParseCutoverMXHosts( cfg.MigrationCutoverMXHosts, cfg.PlatformMailDomain, cfg.StalwartIMAPHost, ), RequireMX: cfg.MigrationCutoverRequireMX, }) googleDWD, err := migration.NewGoogleDWD(cfg.MigrationGoogleServiceAccountJSON) if err != nil { return nil, fmt.Errorf("google dwd: %w", err) } microsoftApp, err := migration.NewMicrosoftApp(migration.MicrosoftAppConfig{ ClientID: firstNonEmpty(cfg.MigrationMicrosoftOAuthClientID, cfg.MailMicrosoftOAuthClientID), ClientSecret: firstNonEmpty(cfg.MigrationMicrosoftOAuthSecret, cfg.MailMicrosoftOAuthSecret), DefaultTenant: firstNonEmpty(cfg.MigrationMicrosoftOAuthTenant, cfg.MailMicrosoftOAuthTenant), }) if err != nil { return nil, fmt.Errorf("microsoft app: %w", err) } migration.ConfigureRateLimit(migration.RateLimitConfig{ MaxRetries: cfg.MigrationRateLimitMaxRetries, BaseDelay: cfg.MigrationRateLimitBaseDelay, MaxDelay: cfg.MigrationRateLimitMaxDelay, }) migration.ConfigureImportBatch(migration.ImportBatchConfig{ Mail: cfg.MigrationImportBatchSize, Drive: cfg.MigrationDriveBatchSize, }) if !opts.WithoutWorkers { go migration.NewWorker( pool, migrationSvc, migrationOAuthSvc, credentialManager, googleDWD, microsoftApp, ncClient, attachmentStorage, cfg.MailAttachmentsBucket, migration.WorkerConfig{ Concurrency: cfg.MigrationWorkerConcurrency, JobLimit: cfg.MigrationWorkerJobLimit, }, ).Start(workerCtx, cfg.MigrationWorkerInterval) } orgPolicyLoader := orgpolicy.NewLoader(pool, cfg) fileScanner := filescan.NewScanner(orgPolicyLoader, slog.Default()) var syncWorker *imapsync.SyncWorker if !opts.WithoutWorkers { syncWorker = imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ Storage: attachmentStorage, AttachBucket: cfg.MailAttachmentsBucket, Automation: autoDispatcher, Hub: hub, FileScanner: fileScanner, Push: pushDispatcher, }) go syncWorker.Start(workerCtx) } sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc) smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) if !opts.WithoutWorkers { go smtp.NewOutboxProcessor( pool, guardedSender, cfg.MailOutboxInterval, cfg.MailOutboxMaxRetries, smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}), ).Start(workerCtx) } sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender) mailHandler.SetHostedService(hostedSvc) mailHandler.SetHostedPlatformDomain(cfg.PlatformMailDomain) migrationHandler := migrationapi.NewHandler(migrationSvc, migrationOAuthSvc, cfg.MailAppURL) provisionHandler := provision.NewHandler(cfg.ProvisionWebhookSecret, cfg.PlatformMailDomain, hostedSvc, ncClient, pool) mailHandler.SetFileScanner(fileScanner) if syncWorker != nil { mailHandler.SetAccountSync(syncWorker) } r := chi.NewRouter() r.Use(httpcors.Middleware(cfg)) r.Use(middleware.TraceID) r.Use(observability.HTTPMetrics) r.Use(middleware.Logging) r.Mount("/api/docs", docs.NewHandler().Routes()) r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { report := healthChecker.Check(req.Context()) statusCode := http.StatusOK if report.Status != "ok" { statusCode = http.StatusServiceUnavailable } w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) _ = json.NewEncoder(w).Encode(report) }) r.Handle("/metrics", promhttp.Handler()) r.Get("/ws", hub.HandleWS) r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback) r.Get("/api/v1/migration/oauth/callback", migrationHandler.OAuthCallback) r.Get("/api/v1/mail/addresses/check", mailHandler.CheckAddressAvailability) r.Get("/api/v1/migration/invite", migrationHandler.GetInvite) r.Post("/internal/provision/user", provisionHandler.ProvisionUser) r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL, cfg.MailAppURL, rdb).Routes()) var driveHandler *drive.Handler var driveSvc *drive.Service var contactsHandler *contacts.Handler if ncClient != nil { driveSvc = drive.NewService(ncClient, hub, pool) driveSvc.SetAutomation(autoDispatcher) driveSvc.SetFileScanner(fileScanner) driveHandler = drive.NewHandlerWithService(driveSvc) mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc}) contactsHandler = contacts.NewHandler(ncClient, pool) contactsHandler.SetAutomation(autoDispatcher) } if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil { officeSvc := office.NewService(ncClient, office.Config{ Enabled: true, DocumentURL: cfg.OnlyOfficeURL, PublicURL: cfg.OnlyOfficePublicURL, APIInternalURL: cfg.OnlyOfficeAPIInternalURL, JWTSecret: cfg.OnlyOfficeJWTSecret, }) officeHandler := office.NewHandler(officeSvc, driveSvc) r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicOffice(officeHandler) } if ncClient != nil && cfg.RichTextEnabled && driveSvc != nil { rtSvc := richtext.NewService(ncClient, richtext.Config{ Enabled: true, HocuspocusPublicURL: cfg.HocuspocusPublicURL, HocuspocusSecret: cfg.HocuspocusSecret, APIInternalURL: cfg.OnlyOfficeAPIInternalURL, StorageMode: cfg.RichTextStorageMode, ExportMirrorFormat: cfg.RichTextExportMirror, }, driveSvc) rtHandler := richtext.NewHandler(rtSvc, driveSvc) r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicRichText(rtHandler) udSvc := ultidraw.NewService(ncClient, ultidraw.Config{ Enabled: true, HocuspocusPublicURL: cfg.HocuspocusPublicURL, HocuspocusSecret: cfg.HocuspocusSecret, APIInternalURL: cfg.OnlyOfficeAPIInternalURL, }, driveSvc) udHandler := ultidraw.NewHandler(udSvc) r.Mount("/api/v1/ultidraw", udHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicUltidraw(udHandler) } if driveHandler != nil { r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) } aiHandler := aiapi.NewHandler(pool, cfg, ncClient, verifierHolder) r.Mount("/api/v1/ai", aiHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) r.Group(func(r chi.Router) { r.Use(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)) r.Use(middleware.EnforceApiTokenPolicy()) r.Mount("/api/v1/users", usersapi.NewHandler(pool, cfg).Routes()) r.Mount("/api/v1/devices", devices.NewHandler(pool).Routes()) adminHandler := admin.NewHandler(pool, auditLogger, cfg, ncClient) adminHandler.SetHostedService(hostedSvc) adminHandler.SetMigrationService(migrationSvc) r.Mount("/api/v1/admin", adminHandler.Routes()) if driveHandler != nil { r.Mount("/api/v1/drive", driveHandler.Routes()) } r.Group(func(r chi.Router) { r.Use(middleware.RequireFullAccount) r.Mount("/api/v1/mail", mailHandler.Routes()) r.Mount("/api/v1/migration", migrationHandler.Routes()) searchHandler := search.NewHandler(pool, search.Options{ Nextcloud: ncClient, Engine: cfg.SearchEngine, MeilisearchURL: cfg.MeilisearchURL, MeilisearchKey: cfg.MeilisearchKey, MeilisearchIndex: cfg.MeilisearchIndex, TypesenseURL: cfg.TypesenseURL, TypesenseKey: cfg.TypesenseKey, TypesenseCollection: cfg.TypesenseCollection, }) r.Get("/api/v1/search", searchHandler.Search) webDiscovery := discovery.NewService(pool) if contactsHandler != nil && contactsHandler.Discovery() != nil { webDiscovery = contactsHandler.Discovery() } r.Get("/api/v1/search/web", search.NewWebHandler(webDiscovery).Search) if driveHandler != nil { r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg, orgPolicyLoader).Routes()) r.Mount("/api/v1/contacts", contactsHandler.Routes()) } r.Mount("/api/v1/meet", meetapi.NewHandler( meetCfg, cfg.JitsiEnabled, cfg.JitsiPublicURL, orgPolicyLoader, pool, ncClient, cfg.MeetTranscriptWebhookSecret, ).Routes()) if photosClient != nil { r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) } }) }) slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) return &App{ Router: r, Pool: pool, Redis: rdb, VerifierHolder: verifierHolder, MailOAuth: mailOAuthSvc, ownsPool: ownsPool, ownsRedis: ownsRedis, cancel: cancel, }, nil } // Close stops workers and releases owned connections. func (a *App) Close() { if a == nil { return } if a.cancel != nil { a.cancel() } if a.ownsPool && a.Pool != nil { a.Pool.Close() } if a.ownsRedis && a.Redis != nil { _ = a.Redis.Close() } } func closeOwned(ownsPool bool, pool *pgxpool.Pool, ownsRedis bool, rdb *redis.Client) { if ownsPool && pool != nil { pool.Close() } if ownsRedis && rdb != nil { _ = rdb.Close() } } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" }