package provision import ( "errors" "log/slog" "net/http" "strings" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/mail/hosted" "github.com/ultisuite/ulti-backend/internal/migration" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/users" ) type Handler struct { secret string platformDomain string hosted *hosted.Service nc *nextcloud.Client db *pgxpool.Pool logger *slog.Logger } func NewHandler(secret, platformDomain string, hostedSvc *hosted.Service, nc *nextcloud.Client, db *pgxpool.Pool) *Handler { return &Handler{ secret: strings.TrimSpace(secret), platformDomain: strings.ToLower(strings.TrimSpace(platformDomain)), hosted: hostedSvc, nc: nc, db: db, logger: slog.Default().With("component", "provision"), } } type provisionUserRequest struct { Email string `json:"email"` Username string `json:"username"` Name string `json:"name"` Password string `json:"password"` ExternalID string `json:"external_id"` } func (h *Handler) ProvisionUser(w http.ResponseWriter, r *http.Request) { if h.secret == "" { apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "provision_disabled", "provisioning webhook not configured", nil) return } if !authorizeProvision(r, h.secret) { apiresponse.WriteError(w, r, http.StatusUnauthorized, "unauthorized", "invalid provision secret", nil) return } req, err := decodeProvisionBody(r) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_json", "invalid request body", nil) return } email := strings.ToLower(strings.TrimSpace(req.Email)) if email == "" { apiresponse.WriteError(w, r, http.StatusBadRequest, "validation_error", "email required", nil) return } if h.platformDomain != "" && !strings.Contains(email, "@") { email = email + "@" + h.platformDomain } else if h.platformDomain != "" && !strings.HasSuffix(email, "@"+h.platformDomain) { local := strings.Split(email, "@")[0] email = local + "@" + h.platformDomain } ctx := r.Context() externalID := strings.TrimSpace(req.ExternalID) userID, err := users.ResolveProvisionUser(ctx, h.db, externalID, email, req.Name) if err != nil { h.logger.Error("resolve user", "error", err, "email", email) apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to provision user", nil) return } skipMailbox, err := migration.HasPendingMigrationInvite(ctx, h.db, email) if err != nil { h.logger.Error("check migration invite", "error", err, "email", email) apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to check migration invite", nil) return } var result hosted.ProvisionMailboxResult if !skipMailbox { result, err = h.hosted.EnsureMailboxProvisioned(ctx, hosted.ProvisionMailboxInput{ UserID: userID, Email: email, DisplayName: req.Name, Password: req.Password, }) if err != nil { if errors.Is(err, hosted.ErrAddressTaken) { apiresponse.WriteError(w, r, http.StatusConflict, "address_taken", err.Error(), nil) return } h.logger.Error("provision mailbox", "error", err, "email", email) apiresponse.WriteError(w, r, http.StatusConflict, "provision_failed", err.Error(), nil) return } } _ = migration.LinkHostedMailboxByEmail(ctx, h.db, userID, email) if h.nc != nil && userID != "" && externalID != "" { if _, err := h.nc.EnsurePrincipal(ctx, email, externalID, req.Name); err != nil { h.logger.Warn("nextcloud provision", "error", err) } } resp := map[string]any{ "user_id": userID, "email": email, } if !skipMailbox { resp["mailbox_id"] = result.Mailbox.ID resp["mail_account_id"] = result.MailAccountID } if skipMailbox { resp["mailbox_deferred"] = true } apiresponse.WriteJSON(w, http.StatusOK, resp) } // CheckAddress validates local part availability (Authentik expression policy or public API). func (h *Handler) CheckAddress(w http.ResponseWriter, r *http.Request) { if h.hosted == nil { apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": true}) return } local := strings.TrimSpace(r.URL.Query().Get("local")) domain := strings.TrimSpace(r.URL.Query().Get("domain")) if domain == "" { domain = h.platformDomain } if local == "" || domain == "" { apiresponse.WriteError(w, r, http.StatusBadRequest, "validation_error", "local and domain required", nil) return } available, err := h.hosted.IsAddressAvailable(r.Context(), domain, local) if err != nil { apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": false, "reason": err.Error()}) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": available}) }