- 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.
167 lines
4.7 KiB
Go
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
|
|
}
|