ultisuite-backend/internal/mail/hosted/dns_verify.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

167 lines
4.7 KiB
Go

package hosted
import (
"context"
"fmt"
"net"
"sort"
"strings"
)
// DNSCheckReport summarizes live DNS checks for a hosted mail domain.
type DNSCheckReport struct {
Domain string `json:"domain"`
TXTVerified bool `json:"txt_verified"`
TXTRecords []string `json:"txt_records,omitempty"`
TXTExpected string `json:"txt_expected,omitempty"`
MXVerified bool `json:"mx_verified"`
MXRecords []string `json:"mx_records"`
ExpectedMX []string `json:"expected_mx"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}
func LookupDomainMX(ctx context.Context, domain string) ([]string, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, fmt.Errorf("domain required")
}
mxRecords, err := (&net.Resolver{}).LookupMX(ctx, domain)
if err != nil {
return nil, err
}
sort.Slice(mxRecords, func(i, j int) bool {
return mxRecords[i].Pref < mxRecords[j].Pref
})
out := make([]string, 0, len(mxRecords))
for _, mx := range mxRecords {
host := strings.TrimSuffix(strings.ToLower(mx.Host), ".")
if host != "" {
out = append(out, host)
}
}
return out, nil
}
func LookupDomainTXT(ctx context.Context, name string) ([]string, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return nil, fmt.Errorf("txt name required")
}
records, err := (&net.Resolver{}).LookupTXT(ctx, name)
if err != nil {
return nil, err
}
out := make([]string, 0, len(records))
for _, record := range records {
record = strings.TrimSpace(record)
if record != "" {
out = append(out, record)
}
}
return out, nil
}
func MXMatchesExpected(mxHosts, expected []string) bool {
if len(mxHosts) == 0 || len(expected) == 0 {
return false
}
for _, mx := range mxHosts {
mx = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(mx)), ".")
for _, want := range expected {
want = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(want)), ".")
if want == "" {
continue
}
if mx == want || strings.HasSuffix(mx, "."+want) {
return true
}
}
}
return false
}
func TXTContainsToken(records []string, token string) bool {
token = strings.TrimSpace(token)
if token == "" {
return false
}
for _, record := range records {
if strings.TrimSpace(record) == token {
return true
}
}
return false
}
func (s *Service) CheckDomainDNS(ctx context.Context, domainID string, expectedMX []string) (DomainRow, DNSCheckReport, error) {
row, err := s.GetDomain(ctx, domainID)
if err != nil {
return DomainRow{}, DNSCheckReport{}, err
}
report := DNSCheckReport{
Domain: row.Name,
ExpectedMX: append([]string(nil), expectedMX...),
TXTExpected: strings.TrimSpace(row.VerificationToken),
}
txtName := "_ultisuite-verify." + row.Name
txtRecords, err := LookupDomainTXT(ctx, txtName)
if err != nil {
report.Errors = append(report.Errors, "txt lookup: "+err.Error())
} else {
report.TXTRecords = txtRecords
report.TXTVerified = TXTContainsToken(txtRecords, row.VerificationToken)
if !report.TXTVerified && row.TXTVerifiedAt != nil {
report.Warnings = append(report.Warnings, "txt record not found but domain was previously verified")
report.TXTVerified = true
}
}
mxRecords, err := LookupDomainMX(ctx, row.Name)
if err != nil {
report.Errors = append(report.Errors, "mx lookup: "+err.Error())
} else {
report.MXRecords = mxRecords
report.MXVerified = MXMatchesExpected(mxRecords, expectedMX)
if !report.MXVerified && row.MXVerifiedAt != nil && len(expectedMX) == 0 {
report.MXVerified = len(mxRecords) > 0
}
}
return row, report, nil
}
func (s *Service) VerifyDomainTXTRecord(ctx context.Context, domainID string) (DomainRow, DNSCheckReport, error) {
row, report, err := s.CheckDomainDNS(ctx, domainID, nil)
if err != nil {
return DomainRow{}, DNSCheckReport{}, err
}
if !report.TXTVerified {
return row, report, fmt.Errorf("txt verification token not found at _ultisuite-verify.%s", row.Name)
}
updated, err := s.MarkDomainVerified(ctx, domainID)
if err != nil {
return row, report, err
}
return updated, report, nil
}
func (s *Service) VerifyDomainMXRecord(ctx context.Context, domainID string, expectedMX []string) (DomainRow, DNSCheckReport, error) {
row, report, err := s.CheckDomainDNS(ctx, domainID, expectedMX)
if err != nil {
return DomainRow{}, DNSCheckReport{}, err
}
if len(expectedMX) == 0 {
report.Warnings = append(report.Warnings, "expected mx hosts not configured")
return row, report, fmt.Errorf("expected mx hosts not configured")
}
if !report.MXVerified {
return row, report, fmt.Errorf("mx records %v do not match expected %v", report.MXRecords, expectedMX)
}
updated, err := s.MarkDomainMXVerified(ctx, domainID)
if err != nil {
return row, report, err
}
return updated, report, nil
}