package migration import ( "context" "encoding/json" "fmt" "strings" "github.com/ultisuite/ulti-backend/internal/mail/hosted" ) // CutoverConfig controls DNS expectations during migration cutover. type CutoverConfig struct { ExpectedMXHosts []string RequireMX bool } // CutoverResult is returned when a migration project enters cutover. type CutoverResult struct { Project Project `json:"project"` DNS hosted.DNSCheckReport `json:"dns"` } var ErrCutoverMXNotReady = fmt.Errorf("migration cutover blocked: mx records not pointing to ultimail") func (s *Service) PreflightCutoverDNS(ctx context.Context, projectID string, cfg CutoverConfig) (hosted.DNSCheckReport, error) { domainID, err := s.projectDomainID(ctx, projectID) if err != nil { return hosted.DNSCheckReport{}, err } if domainID == "" { return hosted.DNSCheckReport{ Warnings: []string{"project has no linked mail domain; mx/txt checks skipped"}, }, nil } if s.hosted == nil { return hosted.DNSCheckReport{}, fmt.Errorf("hosted mail service not configured") } _, report, err := s.hosted.CheckDomainDNS(ctx, domainID, cfg.ExpectedMXHosts) return report, err } func (s *Service) StartCutover(ctx context.Context, projectID string) (CutoverResult, error) { return s.startCutover(ctx, projectID, s.cutover) } func (s *Service) startCutover(ctx context.Context, projectID string, cfg CutoverConfig) (CutoverResult, error) { domainID, err := s.projectDomainID(ctx, projectID) if err != nil { return CutoverResult{}, err } var report hosted.DNSCheckReport if domainID != "" { if s.hosted == nil { return CutoverResult{}, fmt.Errorf("hosted mail service not configured") } var checkErr error _, report, checkErr = s.hosted.CheckDomainDNS(ctx, domainID, cfg.ExpectedMXHosts) if checkErr != nil { return CutoverResult{}, checkErr } if report.TXTVerified { if _, _, err := s.hosted.VerifyDomainTXTRecord(ctx, domainID); err != nil { report.Warnings = append(report.Warnings, "auto txt verify: "+err.Error()) } } if report.MXVerified && len(cfg.ExpectedMXHosts) > 0 { if _, _, err := s.hosted.VerifyDomainMXRecord(ctx, domainID, cfg.ExpectedMXHosts); err != nil { report.Warnings = append(report.Warnings, "auto mx verify: "+err.Error()) } else { report.Warnings = append(report.Warnings, "mx verified and domain marked active") } } else if !report.MXVerified { if cfg.RequireMX { return CutoverResult{DNS: report}, ErrCutoverMXNotReady } report.Warnings = append(report.Warnings, "mx not pointing to ultimail yet; cutover flag set anyway") } } else { report.Warnings = append(report.Warnings, "no domain_id on project; configure mail domain before mx cutover") } rawDNS, _ := json.Marshal(report) _, err = s.db.Exec(ctx, ` UPDATE migration_projects SET status = 'cutover', cutover_at = NOW(), delta_mode = true, cutover_dns_json = $2, updated_at = NOW() WHERE id = $1::uuid `, projectID, rawDNS) if err != nil { return CutoverResult{}, err } _, _ = s.db.Exec(ctx, ` UPDATE migration_jobs SET status = 'pending', error = '', updated_at = NOW() WHERE project_id = $1::uuid AND status = 'completed' `, projectID) sc := newProjectScanner() err = s.db.QueryRow(ctx, ` SELECT `+projectSelectSQL("")+` FROM migration_projects WHERE id = $1::uuid `, projectID).Scan(sc.targets()...) if err != nil { return CutoverResult{}, err } return CutoverResult{Project: sc.result(), DNS: report}, nil } func (s *Service) projectDomainID(ctx context.Context, projectID string) (string, error) { var domainID string err := s.db.QueryRow(ctx, ` SELECT COALESCE(domain_id::text, '') FROM migration_projects WHERE id = $1::uuid `, projectID).Scan(&domainID) return domainID, err } func ParseCutoverMXHosts(raw string, platformMailDomain, stalwartIMAPHost string) []string { var out []string for _, part := range strings.Split(raw, ",") { part = strings.ToLower(strings.TrimSpace(part)) if part != "" { out = append(out, part) } } if len(out) > 0 { return out } platformMailDomain = strings.ToLower(strings.TrimSpace(platformMailDomain)) if platformMailDomain != "" { out = append(out, "mail."+platformMailDomain) } stalwartIMAPHost = strings.ToLower(strings.TrimSpace(stalwartIMAPHost)) if stalwartIMAPHost != "" && !strings.Contains(stalwartIMAPHost, ".") { return out } if stalwartIMAPHost != "" { out = append(out, stalwartIMAPHost) } return out }