- 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.
143 lines
4.4 KiB
Go
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
|
|
}
|