ultisuite-backend/internal/provision/handler.go
R3D347HR4Y 7143a36c19
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(mail): integrate Stalwart hosted mail and migration features
- Added configuration options for Stalwart hosted mail in .env.example.
- Updated Docker Compose to include Stalwart service with health checks.
- Introduced new API endpoints for managing mail domains and migration projects.
- Enhanced Authentik blueprints for user enrollment and post-migration security.
- Updated OAuth handling for Google and Microsoft migration processes.
- Improved error handling and response structures in the mail API.
- Added integration tests for email claiming and migration workflows.
2026-06-13 12:47:08 +02:00

152 lines
4.7 KiB
Go

package provision
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/auth"
"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()
var userID string
externalID := strings.TrimSpace(req.ExternalID)
if externalID != "" {
err := h.db.QueryRow(ctx, `SELECT id::text FROM users WHERE external_id = $1`, externalID).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
userID, err = users.EnsureUser(ctx, h.db, &auth.Claims{
Sub: externalID,
Email: email,
Name: req.Name,
})
}
if err != nil {
h.logger.Error("ensure user", "error", err)
apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to provision user", nil)
return
}
}
result, err := h.hosted.ProvisionMailbox(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
}
if userID != "" {
_ = 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)
}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"user_id": userID,
"email": email,
"mailbox_id": result.Mailbox.ID,
"mail_account_id": result.MailAccountID,
})
}
// 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})
}