ultisuite-backend/internal/migration/cutover_dns.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

143 lines
4.4 KiB
Go

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
}