- 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.
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package admin
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
|
migr "github.com/ultisuite/ulti-backend/internal/migration"
|
|
)
|
|
|
|
const maxAdminMailRequestBody = 1 << 20
|
|
|
|
func (h *Handler) registerMailAdminRoutes(r chi.Router, read, write func(http.Handler) http.Handler) {
|
|
if h.svc.hosted == nil && h.svc.migration == nil {
|
|
return
|
|
}
|
|
r.Route("/mail", func(r chi.Router) {
|
|
if h.svc.hosted != nil {
|
|
r.With(read).Get("/domains", h.ListMailDomains)
|
|
r.With(write).Post("/domains", h.CreateMailDomain)
|
|
r.With(read).Get("/domains/{domainID}", h.GetMailDomain)
|
|
r.With(write).Post("/domains/{domainID}/verify-txt", h.VerifyMailDomainTXT)
|
|
r.With(write).Post("/domains/{domainID}/verify-mx", h.VerifyMailDomainMX)
|
|
}
|
|
})
|
|
r.Route("/migration", func(r chi.Router) {
|
|
if h.svc.migration == nil {
|
|
return
|
|
}
|
|
r.With(read).Get("/projects", h.ListMigrationProjects)
|
|
r.With(write).Post("/projects", h.CreateMigrationProject)
|
|
r.With(write).Post("/projects/{projectID}/activate", h.ActivateMigrationProject)
|
|
r.With(read).Get("/projects/{projectID}/cutover-dns", h.PreflightMigrationCutoverDNS)
|
|
r.With(write).Post("/projects/{projectID}/cutover", h.StartMigrationCutover)
|
|
r.With(write).Post("/projects/{projectID}/invites", h.CreateMigrationInvite)
|
|
r.With(write).Post("/projects/{projectID}/invites/import", h.ImportMigrationInvites)
|
|
r.With(read).Get("/projects/{projectID}/jobs", h.ListMigrationProjectJobs)
|
|
r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit", h.ListMigrationJobAudit)
|
|
r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit/summary", h.MigrationJobAuditSummary)
|
|
r.With(write).Post("/projects/{projectID}/jobs/retry-failed", h.RetryMigrationFailedJobs)
|
|
r.With(write).Post("/projects/{projectID}/jobs/{jobID}/retry", h.RetryMigrationJob)
|
|
r.With(write).Post("/projects/{projectID}/jobs/{jobID}/reset-cursor", h.ResetMigrationJobCursor)
|
|
r.With(read).Get("/microsoft/admin-consent-url", h.MicrosoftMigrationAdminConsentURL)
|
|
r.With(read).Get("/microsoft/admin-consents", h.ListMicrosoftAdminConsents)
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ListMailDomains(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := h.svc.hosted.ListDomains(r.Context())
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domains": rows})
|
|
}
|
|
|
|
type createMailDomainRequest struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func (h *Handler) CreateMailDomain(w http.ResponseWriter, r *http.Request) {
|
|
var req createMailDomainRequest
|
|
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
|
|
return
|
|
}
|
|
row, err := h.svc.hosted.CreateDomain(r.Context(), req.Name, false)
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusCreated, row)
|
|
}
|
|
|
|
func (h *Handler) GetMailDomain(w http.ResponseWriter, r *http.Request) {
|
|
row, err := h.svc.hosted.GetDomain(r.Context(), chi.URLParam(r, "domainID"))
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
func (h *Handler) VerifyMailDomainTXT(w http.ResponseWriter, r *http.Request) {
|
|
domainID := chi.URLParam(r, "domainID")
|
|
row, report, err := h.svc.hosted.VerifyDomainTXTRecord(r.Context(), domainID)
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "dns_txt_not_verified", err.Error(), map[string]any{"dns": report})
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domain": row, "dns": report})
|
|
}
|
|
|
|
func (h *Handler) VerifyMailDomainMX(w http.ResponseWriter, r *http.Request) {
|
|
domainID := chi.URLParam(r, "domainID")
|
|
expected := h.migrationCutoverConfig().ExpectedMXHosts
|
|
row, report, err := h.svc.hosted.VerifyDomainMXRecord(r.Context(), domainID, expected)
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "dns_mx_not_verified", err.Error(), map[string]any{"dns": report})
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domain": row, "dns": report})
|
|
}
|
|
|
|
type createMigrationProjectRequest struct {
|
|
Name string `json:"name"`
|
|
SourceProvider string `json:"source_provider"`
|
|
DomainID string `json:"domain_id"`
|
|
AuthMode string `json:"auth_mode"`
|
|
}
|
|
|
|
func (h *Handler) CreateMigrationProject(w http.ResponseWriter, r *http.Request) {
|
|
var req createMigrationProjectRequest
|
|
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
|
|
return
|
|
}
|
|
row, err := h.svc.migration.CreateProject(r.Context(), req.Name, req.SourceProvider, req.DomainID, req.AuthMode)
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusCreated, row)
|
|
}
|
|
|
|
func (h *Handler) ListMigrationProjects(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := h.svc.migration.ListProjects(r.Context())
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"projects": rows})
|
|
}
|
|
|
|
func (h *Handler) ActivateMigrationProject(w http.ResponseWriter, r *http.Request) {
|
|
row, err := h.svc.migration.ActivateProject(r.Context(), chi.URLParam(r, "projectID"))
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
func (h *Handler) PreflightMigrationCutoverDNS(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
report, err := h.svc.migration.PreflightCutoverDNS(r.Context(), chi.URLParam(r, "projectID"), h.migrationCutoverConfig())
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"dns": report})
|
|
}
|
|
|
|
func (h *Handler) StartMigrationCutover(w http.ResponseWriter, r *http.Request) {
|
|
result, err := h.svc.migration.StartCutover(r.Context(), chi.URLParam(r, "projectID"))
|
|
if errors.Is(err, migr.ErrCutoverMXNotReady) {
|
|
apiresponse.WriteError(w, r, http.StatusConflict, "migration_cutover_mx_not_ready", err.Error(), map[string]any{
|
|
"dns": result.DNS,
|
|
"project": result.Project,
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (h *Handler) migrationCutoverConfig() migr.CutoverConfig {
|
|
if h.svc.cfg == nil {
|
|
return migr.CutoverConfig{}
|
|
}
|
|
return migr.CutoverConfig{
|
|
ExpectedMXHosts: migr.ParseCutoverMXHosts(
|
|
h.svc.cfg.MigrationCutoverMXHosts,
|
|
h.svc.cfg.PlatformMailDomain,
|
|
h.svc.cfg.StalwartIMAPHost,
|
|
),
|
|
RequireMX: h.svc.cfg.MigrationCutoverRequireMX,
|
|
}
|
|
}
|
|
|
|
type createMigrationInviteRequest struct {
|
|
Email string `json:"email"`
|
|
AlternateEmails []string `json:"alternate_emails,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) CreateMigrationInvite(w http.ResponseWriter, r *http.Request) {
|
|
var req createMigrationInviteRequest
|
|
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
|
|
return
|
|
}
|
|
row, err := h.svc.migration.CreateInvite(r.Context(), chi.URLParam(r, "projectID"), req.Email, req.AlternateEmails)
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusCreated, row)
|
|
}
|
|
|
|
func (h *Handler) ImportMigrationInvites(w http.ResponseWriter, r *http.Request) {
|
|
var emails []string
|
|
contentType := r.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "multipart/form-data") {
|
|
file, _, err := r.FormFile("file")
|
|
if err == nil {
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
for {
|
|
record, err := reader.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
|
Field: "file", Message: "invalid csv",
|
|
}))
|
|
return
|
|
}
|
|
if len(record) > 0 {
|
|
emails = append(emails, record[0])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(emails) == 0 {
|
|
var body struct {
|
|
Emails []string `json:"emails"`
|
|
}
|
|
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &body); err != nil {
|
|
return
|
|
}
|
|
emails = body.Emails
|
|
}
|
|
count, err := h.svc.migration.ImportInvites(r.Context(), chi.URLParam(r, "projectID"), emails)
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"imported": count})
|
|
}
|
|
|
|
func (h *Handler) MicrosoftMigrationAdminConsentURL(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
consentURL, err := h.svc.migration.MicrosoftAdminConsentURL(
|
|
r.URL.Query().Get("tenant"),
|
|
r.URL.Query().Get("project_id"),
|
|
)
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "admin_consent_unavailable", err.Error(), nil)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"url": consentURL})
|
|
}
|
|
|
|
func (h *Handler) ListMicrosoftAdminConsents(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
rows, err := h.svc.migration.ListMicrosoftAdminConsents(r.Context())
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"consents": rows})
|
|
}
|
|
|
|
func (h *Handler) ListMigrationProjectJobs(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
rows, err := h.svc.migration.ListProjectJobs(r.Context(), chi.URLParam(r, "projectID"))
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"jobs": rows})
|
|
}
|
|
|
|
func (h *Handler) RetryMigrationJob(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
row, err := h.svc.migration.RetryJob(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID"))
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_retryable", err.Error(), nil)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
func (h *Handler) ResetMigrationJobCursor(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
row, err := h.svc.migration.ResetJobCursor(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID"))
|
|
if err != nil {
|
|
status := http.StatusNotFound
|
|
code := "migration_job_not_resettable"
|
|
if strings.Contains(err.Error(), "running") {
|
|
status = http.StatusConflict
|
|
} else if strings.Contains(err.Error(), "not found") {
|
|
code = "migration_job_not_found"
|
|
}
|
|
apiresponse.WriteError(w, r, status, code, err.Error(), nil)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
func (h *Handler) RetryMigrationFailedJobs(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
count, err := h.svc.migration.RetryFailedJobs(r.Context(), chi.URLParam(r, "projectID"))
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"retried": count})
|
|
}
|
|
|
|
func (h *Handler) ListMigrationJobAudit(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
params, err := query.ParseListRequest(r)
|
|
if err != nil {
|
|
apivalidate.WriteQueryError(w, r, err)
|
|
return
|
|
}
|
|
items, pagination, err := h.svc.migration.ListJobAudit(
|
|
r.Context(),
|
|
chi.URLParam(r, "projectID"),
|
|
chi.URLParam(r, "jobID"),
|
|
r.URL.Query().Get("status"),
|
|
params,
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil)
|
|
return
|
|
}
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"pagination": pagination,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) MigrationJobAuditSummary(w http.ResponseWriter, r *http.Request) {
|
|
if h.svc.migration == nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
summary, err := h.svc.migration.JobAuditSummary(
|
|
r.Context(),
|
|
chi.URLParam(r, "projectID"),
|
|
chi.URLParam(r, "jobID"),
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil)
|
|
return
|
|
}
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, summary)
|
|
}
|