- 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.
152 lines
4.7 KiB
Go
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})
|
|
}
|