From fc326498f3120184a2bbb694c112db67e1dcc024 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Wed, 2 Sep 2026 11:16:37 +0200
Subject: [PATCH 1/4] feat(service): update domain.go
---
api/internal/cron/jobs/domain.go | 11 ++-
api/internal/model/domain.go | 6 ++
api/internal/service/domain.go | 77 ++++++++++++------
api/internal/transport/api/domain.go | 12 +--
api/internal/utils/dns.go | 117 ++++++++++++++++++++++++---
api/internal/utils/dns_test.go | 93 +++++++++++++++++++++
6 files changed, 273 insertions(+), 43 deletions(-)
diff --git a/api/internal/cron/jobs/domain.go b/api/internal/cron/jobs/domain.go
index c589ceed..c7a54404 100644
--- a/api/internal/cron/jobs/domain.go
+++ b/api/internal/cron/jobs/domain.go
@@ -3,6 +3,7 @@ package jobs
import (
"context"
"log"
+ "strings"
"time"
"gorm.io/gorm"
@@ -69,7 +70,15 @@ func VerifyDomainsJob(cfg config.Config, db *gorm.DB) {
}
// Send records check (SPF, DKIM, DMARC)
- if err := svc.VerifyDomainSend(ctx, domain.Name, domain.UserID); err != nil {
+ if checks, err := svc.VerifyDomainSend(ctx, domain.Name, domain.UserID); err != nil {
+ var failedChecks []string
+ for _, c := range checks {
+ if !c.Passed {
+ failedChecks = append(failedChecks, c.Name)
+ }
+ }
+ log.Printf("VerifyDomainsJob: send verification failed for domain %s: %s", domain.Name, strings.Join(failedChecks, ", "))
+
if dbErr := db.Model(&model.Domain{}).Where("id = ?", domain.ID).Updates(map[string]any{
"send_verified_at": nil,
}).Error; dbErr != nil {
diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go
index 3990434c..72f1dd46 100644
--- a/api/internal/model/domain.go
+++ b/api/internal/model/domain.go
@@ -30,3 +30,9 @@ type DNSConfig struct {
DKIM []string `json:"dkim_selectors"`
Hosts []string `json:"mx_hosts"`
}
+
+type RecordCheck struct {
+ Name string `json:"name"`
+ Passed bool `json:"passed"`
+ Error string `json:"error,omitempty"`
+}
diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go
index 4fc14929..373c8844 100644
--- a/api/internal/service/domain.go
+++ b/api/internal/service/domain.go
@@ -29,6 +29,7 @@ var (
ErrDNSLookupDKIM = errors.New("Unable to verify domain DNS records. Please ensure the correct DKIM records are set or try again later.")
ErrDNSLookupDMARC = errors.New("Unable to verify domain DNS records. Please ensure the correct DMARC record is set or try again later.")
ErrDNSLookupMX = errors.New("Unable to verify domain DNS records. Please ensure the correct MX records are set or try again later.")
+ ErrDNSLookupSend = errors.New("Unable to verify domain DNS records. Please review the individual record results.")
)
type DomainStore interface {
@@ -298,11 +299,11 @@ func (s *Service) VerifyOwnerExistingDomain(ctx context.Context, domainId string
return nil
}
-func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, userID string) error {
+func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, userID string) ([]model.RecordCheck, error) {
domain, err := s.GetDomain(ctx, domainId, userID)
if err != nil {
log.Printf("error getting domain for DNS record verification: %s", err.Error())
- return ErrGetDomain
+ return nil, ErrGetDomain
}
// verify, err := s.GetOwnerVerifyRecordExistingDomain(ctx, domainId, userID)
@@ -328,22 +329,26 @@ func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, u
// return ErrDNSLookupOwner
// }
+ mxCheck := model.RecordCheck{Name: "mx", Passed: true}
err = s.VerifyDomainMX(ctx, domain.Name, userID)
if err != nil {
+ mxCheck.Passed = false
+ mxCheck.Error = err.Error()
domain.MXVerifiedAt = nil
if updateErr := s.UpdateDomain(ctx, domain); updateErr != nil {
log.Printf("error nulling mx_verified_at for domain %s: %s", domain.Name, updateErr.Error())
}
- return err
+ return []model.RecordCheck{mxCheck}, err
}
- err = s.VerifyDomainSend(ctx, domain.Name, userID)
+ checks, err := s.VerifyDomainSend(ctx, domain.Name, userID)
+ checks = append([]model.RecordCheck{mxCheck}, checks...)
if err != nil {
domain.SendVerifiedAt = nil
if updateErr := s.UpdateDomain(ctx, domain); updateErr != nil {
log.Printf("error nulling send_verified_at for domain %s: %s", domain.Name, updateErr.Error())
}
- return err
+ return checks, err
}
now := time.Now()
@@ -354,10 +359,10 @@ func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, u
err = s.UpdateDomain(ctx, domain)
if err != nil {
log.Printf("error updating domain verification timestamps: %s", err.Error())
- return ErrUpdateDomain
+ return checks, ErrUpdateDomain
}
- return nil
+ return checks, nil
}
func (s *Service) VerifyDomainMX(ctx context.Context, domain string, userID string) error {
@@ -383,49 +388,71 @@ func (s *Service) VerifyDomainMX(ctx context.Context, domain string, userID stri
return nil
}
-func (s *Service) VerifyDomainSend(ctx context.Context, domain string, userID string) error {
+func (s *Service) VerifyDomainSend(ctx context.Context, domain string, userID string) ([]model.RecordCheck, error) {
dnsConfig, err := s.GetDNSConfig(ctx, userID)
if err != nil {
log.Printf("error getting DNS config for domain MX verification: %s", err.Error())
- return ErrGetDNSConfig
+ return nil, ErrGetDNSConfig
}
+ var checks []model.RecordCheck
+ failed := false
+
// SPF record
- ok, err := utils.LookupTXTContains(domain, "v=spf1 include:spf."+dnsConfig.Domain+" -all")
+ spfCheck := model.RecordCheck{Name: "spf"}
+ ok, err := utils.LookupSPF(domain, "spf."+dnsConfig.Domain)
if err != nil {
log.Printf("error looking up TXT record for domain SPF verification: %s", err.Error())
- return ErrDNSLookupSPF
+ spfCheck.Error = ErrDNSLookupSPF.Error()
+ } else if !ok {
+ spfCheck.Error = ErrDNSLookupSPF.Error()
+ } else {
+ spfCheck.Passed = true
}
-
- if !ok {
- return ErrDNSLookupSPF
+ if !spfCheck.Passed {
+ failed = true
}
+ checks = append(checks, spfCheck)
// DKIM records
for _, selector := range dnsConfig.DKIM {
+ dkimCheck := model.RecordCheck{Name: "dkim:" + selector}
ok, err := utils.LookupCNAME(selector+"._domainkey."+domain, selector+"._domainkey."+dnsConfig.Domain)
if err != nil {
log.Printf("error looking up CNAME record for selector %s in domain DKIM verification: %s", selector, err.Error())
- return ErrDNSLookupDKIM
- }
-
- if !ok {
+ dkimCheck.Error = ErrDNSLookupDKIM.Error()
+ } else if !ok {
log.Printf("DKIM record not found for selector %s in domain DKIM verification", selector)
- return ErrDNSLookupDKIM
+ dkimCheck.Error = ErrDNSLookupDKIM.Error()
+ } else {
+ dkimCheck.Passed = true
}
+ if !dkimCheck.Passed {
+ failed = true
+ }
+ checks = append(checks, dkimCheck)
}
// DMARC record
- ok, err = utils.LookupTXTContains("_dmarc."+domain, "v=DMARC1; p=quarantine; adkim=s")
+ dmarcCheck := model.RecordCheck{Name: "dmarc"}
+ ok, err = utils.LookupDMARC("_dmarc." + domain)
if err != nil {
log.Printf("error looking up TXT record for domain DMARC verification: %s", err.Error())
- return ErrDNSLookupDMARC
+ dmarcCheck.Error = ErrDNSLookupDMARC.Error()
+ } else if !ok {
+ log.Printf("DMARC record not found for domain DMARC verification")
+ dmarcCheck.Error = ErrDNSLookupDMARC.Error()
+ } else {
+ dmarcCheck.Passed = true
}
+ if !dmarcCheck.Passed {
+ failed = true
+ }
+ checks = append(checks, dmarcCheck)
- if !ok {
- log.Printf("DMARC record not found for domain DMARC verification")
- return ErrDNSLookupDMARC
+ if failed {
+ return checks, ErrDNSLookupSend
}
- return nil
+ return checks, nil
}
diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go
index 9754c261..8352b644 100644
--- a/api/internal/transport/api/domain.go
+++ b/api/internal/transport/api/domain.go
@@ -31,7 +31,7 @@ type DomainService interface {
PostDomain(context.Context, model.Domain) (model.Domain, error)
UpdateDomain(context.Context, model.Domain) error
DeleteDomain(context.Context, string, string) error
- VerifyDomainDNSRecords(context.Context, string, string) error
+ VerifyDomainDNSRecords(context.Context, string, string) ([]model.RecordCheck, error)
}
// @Summary Get custom domains
@@ -217,22 +217,24 @@ func (h *Handler) DeleteDomain(c *fiber.Ctx) error {
// @Produce json
// @Security ApiKeyAuth
// @Param id path string true "Domain ID"
-// @Success 200 {object} map[string]string "message"
-// @Failure 400 {object} ErrorRes
+// @Success 200 {object} map[string]interface{} "message, checks"
+// @Failure 400 {object} map[string]interface{} "error, checks"
// @Router /domains/{id}/verify-dns [post]
func (h *Handler) VerifyDomainDNSRecords(c *fiber.Ctx) error {
userID := auth.GetUserID(c)
domainID := c.Params("id")
- err := h.Service.VerifyDomainDNSRecords(c.Context(), domainID, userID)
+ checks, err := h.Service.VerifyDomainDNSRecords(c.Context(), domainID, userID)
if err != nil {
return c.Status(400).JSON(fiber.Map{
- "error": err.Error(),
+ "error": err.Error(),
+ "checks": checks,
})
}
return c.JSON(fiber.Map{
"message": DNSRecordVerificationSuccess,
+ "checks": checks,
})
}
diff --git a/api/internal/utils/dns.go b/api/internal/utils/dns.go
index 6468d997..7c8ec461 100644
--- a/api/internal/utils/dns.go
+++ b/api/internal/utils/dns.go
@@ -17,6 +17,20 @@ func stripDot(s string) string {
return strings.TrimSuffix(s, ".")
}
+// lookupTXTRecords looks up TXT records for host. Not-found/permanent DNS
+// errors are treated as a plain empty result rather than an error.
+func lookupTXTRecords(host string) ([]string, error) {
+ records, err := net.LookupTXT(host)
+ if err != nil {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) {
+ return nil, nil
+ }
+ return nil, ErrLookupTXT
+ }
+ return records, nil
+}
+
// LookupTXTExact looks up TXT records for host and returns true if any record
// is an exact match to value (trailing dots stripped before comparison).
//
@@ -24,13 +38,9 @@ func stripDot(s string) string {
//
// LookupTXTExact("example.com", "service-verify=9487e243822f333d782eabe1115302643b222ef55072c8e77abf75335950a61a")
func LookupTXTExact(host, value string) (bool, error) {
- records, err := net.LookupTXT(host)
+ records, err := lookupTXTRecords(host)
if err != nil {
- var dnsErr *net.DNSError
- if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) {
- return false, nil
- }
- return false, ErrLookupTXT
+ return false, err
}
want := stripDot(value)
@@ -50,13 +60,9 @@ func LookupTXTExact(host, value string) (bool, error) {
// LookupTXTContains("example.com", "v=spf1 include:spf.example.net -all")
// LookupTXTContains("_dmarc.example.com", "v=DMARC1; p=quarantine; adkim=s")
func LookupTXTContains(host, value string) (bool, error) {
- records, err := net.LookupTXT(host)
+ records, err := lookupTXTRecords(host)
if err != nil {
- var dnsErr *net.DNSError
- if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) {
- return false, nil
- }
- return false, ErrLookupTXT
+ return false, err
}
want := stripDot(value)
@@ -68,6 +74,93 @@ func LookupTXTContains(host, value string) (bool, error) {
return false, nil
}
+// validSPFRecord reports whether record is a v=spf1 record that authorizes
+// requiredMechanism (an "include:" target, e.g. "spf.example.net", or the bare
+// "mx" mechanism) and terminates in a "-all"/"~all" mechanism. Mechanism order
+// and any additional mechanisms present are ignored.
+func validSPFRecord(record, requiredMechanism string) bool {
+ fields := strings.Fields(record)
+ if len(fields) == 0 || !strings.EqualFold(fields[0], "v=spf1") {
+ return false
+ }
+
+ wantInclude := "include:" + strings.ToLower(requiredMechanism)
+ hasMechanism := false
+ hasAll := false
+ for _, f := range fields[1:] {
+ switch strings.ToLower(f) {
+ case wantInclude, "mx":
+ hasMechanism = true
+ case "-all", "~all":
+ hasAll = true
+ }
+ }
+
+ return hasMechanism && hasAll
+}
+
+// validDMARCRecord reports whether record is a v=DMARC1 record with a
+// p=quarantine or p=reject policy tag, regardless of tag order.
+func validDMARCRecord(record string) bool {
+ values := make(map[string]string)
+ for _, tag := range strings.Split(record, ";") {
+ tag = strings.TrimSpace(tag)
+ key, value, ok := strings.Cut(tag, "=")
+ if !ok {
+ continue
+ }
+ values[strings.ToLower(strings.TrimSpace(key))] = strings.ToLower(strings.TrimSpace(value))
+ }
+
+ if values["v"] != "dmarc1" {
+ return false
+ }
+ return values["p"] == "quarantine" || values["p"] == "reject"
+}
+
+// LookupSPF looks up the SPF TXT record for host and returns true if it
+// authorizes requiredMechanism (an "include:" target or the bare "mx"
+// mechanism) and ends in a "-all"/"~all" mechanism, regardless of mechanism
+// order or additional mechanisms present.
+//
+// Example use:
+//
+// LookupSPF("example.com", "spf.example.net")
+func LookupSPF(host, requiredMechanism string) (bool, error) {
+ records, err := lookupTXTRecords(host)
+ if err != nil {
+ return false, err
+ }
+
+ for _, r := range records {
+ if validSPFRecord(stripDot(r), requiredMechanism) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// LookupDMARC looks up the DMARC TXT record for host (typically
+// "_dmarc."+domain) and returns true if it has a p=quarantine or p=reject
+// policy, regardless of tag order.
+//
+// Example use:
+//
+// LookupDMARC("_dmarc.example.com")
+func LookupDMARC(host string) (bool, error) {
+ records, err := lookupTXTRecords(host)
+ if err != nil {
+ return false, err
+ }
+
+ for _, r := range records {
+ if validDMARCRecord(stripDot(r)) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
// LookupMX looks up MX records for host and returns true if any MX entry's
// hostname matches target (trailing dots stripped, case-insensitive).
// The MX priority/preference value is not checked.
diff --git a/api/internal/utils/dns_test.go b/api/internal/utils/dns_test.go
index e4eeafe1..0d959f34 100644
--- a/api/internal/utils/dns_test.go
+++ b/api/internal/utils/dns_test.go
@@ -129,6 +129,99 @@ func TestLookupMX_TrailingDot(t *testing.T) {
}
}
+// validSPFRecord tests
+
+func TestValidSPFRecord(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ requiredMechanism string
+ want bool
+ }{
+ {"exact match", "v=spf1 include:spf.mailx.net -all", "spf.mailx.net", true},
+ {"softfail all", "v=spf1 include:spf.mailx.net ~all", "spf.mailx.net", true},
+ {"extra includes and reordered", "v=spf1 include:_spf.google.com include:spf.mailx.net ~all", "spf.mailx.net", true},
+ {"mx mechanism instead of include", "v=spf1 mx -all", "spf.mailx.net", true},
+ {"case-insensitive version and mechanism", "V=SPF1 INCLUDE:SPF.MAILX.NET -ALL", "spf.mailx.net", true},
+ {"missing required mechanism", "v=spf1 include:spf.other.net -all", "spf.mailx.net", false},
+ {"missing all mechanism", "v=spf1 include:spf.mailx.net", "spf.mailx.net", false},
+ {"neutral all not accepted", "v=spf1 include:spf.mailx.net ?all", "spf.mailx.net", false},
+ {"not an spf record", "some other txt record", "spf.mailx.net", false},
+ {"wrong spf version", "v=spf2 include:spf.mailx.net -all", "spf.mailx.net", false},
+ {"empty record", "", "spf.mailx.net", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := validSPFRecord(tc.record, tc.requiredMechanism)
+ if got != tc.want {
+ t.Errorf("validSPFRecord(%q, %q) = %v, want %v", tc.record, tc.requiredMechanism, got, tc.want)
+ }
+ })
+ }
+}
+
+// validDMARCRecord tests
+
+func TestValidDMARCRecord(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ want bool
+ }{
+ {"quarantine", "v=DMARC1; p=quarantine; adkim=s", true},
+ {"reject", "v=DMARC1; p=reject", true},
+ {"reordered tags", "adkim=s; p=reject; v=DMARC1", true},
+ {"case-insensitive", "V=DMARC1; P=Reject", true},
+ {"none policy rejected", "v=DMARC1; p=none", false},
+ {"missing version tag", "p=reject", false},
+ {"missing policy tag", "v=DMARC1; adkim=s", false},
+ {"empty record", "", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := validDMARCRecord(tc.record)
+ if got != tc.want {
+ t.Errorf("validDMARCRecord(%q) = %v, want %v", tc.record, got, tc.want)
+ }
+ })
+ }
+}
+
+// LookupSPF tests
+
+func TestLookupSPF_NotFound(t *testing.T) {
+ found, err := LookupSPF("nonexistent.invalid", "spf.example.net")
+ if err != nil {
+ t.Fatalf("expected nil error for non-existent domain, got: %v", err)
+ }
+ if found {
+ t.Fatal("expected false for non-existent domain")
+ }
+}
+
+func TestLookupSPF_Mismatch(t *testing.T) {
+ // gmail.com publishes an SPF record but never includes this mechanism.
+ found, err := LookupSPF("gmail.com", "spf.this-will-never-exist.example")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if found {
+ t.Fatal("expected false for mismatched SPF mechanism")
+ }
+}
+
+// LookupDMARC tests
+
+func TestLookupDMARC_NotFound(t *testing.T) {
+ found, err := LookupDMARC("_dmarc.nonexistent.invalid")
+ if err != nil {
+ t.Fatalf("expected nil error for non-existent domain, got: %v", err)
+ }
+ if found {
+ t.Fatal("expected false for non-existent domain")
+ }
+}
+
// LookupCNAME tests
func TestLookupCNAME_NotFound(t *testing.T) {
From 30d1d86078ba04e867097704110a54c72281eccc Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Wed, 2 Sep 2026 11:23:50 +0200
Subject: [PATCH 2/4] refactor(utils): update dns.go
---
api/internal/utils/dns.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/internal/utils/dns.go b/api/internal/utils/dns.go
index 7c8ec461..9fd8608e 100644
--- a/api/internal/utils/dns.go
+++ b/api/internal/utils/dns.go
@@ -103,7 +103,7 @@ func validSPFRecord(record, requiredMechanism string) bool {
// p=quarantine or p=reject policy tag, regardless of tag order.
func validDMARCRecord(record string) bool {
values := make(map[string]string)
- for _, tag := range strings.Split(record, ";") {
+ for tag := range strings.SplitSeq(record, ";") {
tag = strings.TrimSpace(tag)
key, value, ok := strings.Cut(tag, "=")
if !ok {
From 40b8fd64d9eb00a3b64eb0fc4bb902cd4872b8bd Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Wed, 2 Sep 2026 11:35:39 +0200
Subject: [PATCH 3/4] feat(app): update DomainVerify.vue
---
app/src/components/DomainVerify.vue | 49 ++++++++++++++++++++++++++---
1 file changed, 45 insertions(+), 4 deletions(-)
diff --git a/app/src/components/DomainVerify.vue b/app/src/components/DomainVerify.vue
index 03d459d9..5cae6e6d 100644
--- a/app/src/components/DomainVerify.vue
+++ b/app/src/components/DomainVerify.vue
@@ -153,6 +153,16 @@
+
+
Verification Results:
+
+
+
+
+ {{ row.label }}
+
+
+
@@ -173,16 +184,25 @@