From 718b624fb86deb985b81eb8987bf250aeea4bed0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 Oct 2025 10:10:58 +0700 Subject: [PATCH] Add domain only tests --- api/openapi.yaml | 70 +++++++ internal/api/handlers.go | 51 ++++++ pkg/analyzer/analyzer.go | 11 ++ pkg/analyzer/dns.go | 64 +++++++ pkg/analyzer/scoring.go | 20 ++ web/src/lib/components/DnsRecordsCard.svelte | 147 ++++++++------- web/src/lib/components/index.ts | 8 + web/src/routes/+page.svelte | 7 + web/src/routes/domain/+page.svelte | 176 ++++++++++++++++++ web/src/routes/domain/[domain]/+page.svelte | 181 +++++++++++++++++++ 10 files changed, 665 insertions(+), 70 deletions(-) create mode 100644 web/src/routes/domain/+page.svelte create mode 100644 web/src/routes/domain/[domain]/+page.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 7d2ec2c..8c8a836 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -169,6 +169,39 @@ paths: schema: $ref: '#/components/schemas/Error' + /domain: + post: + tags: + - tests + summary: Test a domain's email configuration + description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately. + operationId: testDomain + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DomainTestRequest' + responses: + '200': + description: Domain test completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DomainTestResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /status: get: tags: @@ -1112,3 +1145,40 @@ components: details: type: string description: Additional error details + + DomainTestRequest: + type: object + required: + - domain + properties: + domain: + type: string + pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + description: Domain name to test (e.g., example.com) + example: "example.com" + + DomainTestResponse: + type: object + required: + - domain + - score + - grade + - dns_results + properties: + domain: + type: string + description: The tested domain name + example: "example.com" + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall domain configuration score (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A" + dns_results: + $ref: '#/components/schemas/DNSResults' diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 7489f99..fd57579 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -40,6 +40,7 @@ import ( // This interface breaks the circular dependency with pkg/analyzer type EmailAnalyzer interface { AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) + AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string) } // APIHandler implements the ServerInterface for handling API requests @@ -290,3 +291,53 @@ func (h *APIHandler) GetStatus(c *gin.Context) { Uptime: &uptime, }) } + +// TestDomain performs synchronous domain analysis +// (POST /domain) +func (h *APIHandler) TestDomain(c *gin.Context) { + var request DomainTestRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: stringPtr(err.Error()), + }) + return + } + + // Perform domain analysis + dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) + + // Convert grade string to DomainTestResponseGrade enum + var responseGrade DomainTestResponseGrade + switch grade { + case "A+": + responseGrade = DomainTestResponseGradeA + case "A": + responseGrade = DomainTestResponseGradeA1 + case "B": + responseGrade = DomainTestResponseGradeB + case "C": + responseGrade = DomainTestResponseGradeC + case "D": + responseGrade = DomainTestResponseGradeD + case "E": + responseGrade = DomainTestResponseGradeE + case "F": + responseGrade = DomainTestResponseGradeF + default: + responseGrade = DomainTestResponseGradeF + } + + // Build response + response := DomainTestResponse{ + Domain: request.Domain, + Score: score, + Grade: responseGrade, + DnsResults: *dnsResults, + } + + c.JSON(http.StatusOK, response) +} diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 99b7b52..1cc5bf1 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -108,3 +108,14 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt return reportJSON, nil } + +// AnalyzeDomain performs DNS analysis for a domain and returns the results +func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) { + // Perform DNS analysis + dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) + + // Calculate score + score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults) + + return dnsResults, score, grade +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index c76359c..57226c6 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -124,6 +124,70 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic return results } +// AnalyzeDomainOnly performs DNS validation for a domain without email context +// This is useful for checking domain configuration without sending an actual email +func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults { + results := &api.DNSResults{ + FromDomain: domain, + } + + // Check MX records + results.FromMxRecords = d.checkMXRecords(domain) + + // Check SPF records + results.SpfRecords = d.checkSPFRecords(domain) + + // Check DMARC record + results.DmarcRecord = d.checkDMARCRecord(domain) + + // Check BIMI record with default selector + results.BimiRecord = d.checkBIMIRecord(domain, "default") + + return results +} + +// CalculateDomainOnlyScore calculates the DNS score for domain-only tests +// Returns a score from 0-100 where higher is better +// This version excludes PTR and DKIM checks since they require email context +func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) { + if results == nil { + return 0, "" + } + + score := 0 + + // MX Records: 30 points (only one domain to check) + mxScore := d.calculateMXScore(results) + // Since calculateMXScore checks both From and RP domains, + // and we only have From domain, we use the full score + score += 30 * mxScore / 100 + + // SPF Records: 30 points + score += 30 * d.calculateSPFScore(results) / 100 + + // DMARC Record: 40 points + score += 40 * d.calculateDMARCScore(results) / 100 + + // BIMI Record: only bonus + if results.BimiRecord != nil && results.BimiRecord.Valid { + if score >= 100 { + return 100, "A+" + } + } + + // Ensure score doesn't exceed maximum + if score > 100 { + score = 100 + } + + // Ensure score is non-negative + if score < 0 { + score = 0 + } + + return score, ScoreToGradeKind(score) +} + // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better // senderIP is the original sender IP address used for FCrDNS verification diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index ae91d4f..0a23388 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -45,6 +45,26 @@ func ScoreToGrade(score int) string { } } +// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation +func ScoreToGradeKind(score int) string { + switch { + case score > 100: + return "A+" + case score >= 90: + return "A" + case score >= 80: + return "B" + case score >= 60: + return "C" + case score >= 45: + return "D" + case score >= 30: + return "E" + default: + return "F" + } +} + // ScoreToReportGrade converts a percentage score to an api.ReportGrade func ScoreToReportGrade(score int) api.ReportGrade { return api.ReportGrade(ScoreToGrade(score)) diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 2b3c99c..337f7c1 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -17,9 +17,10 @@ dnsGrade?: string; dnsScore?: number; receivedChain?: ReceivedHop[]; + domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view) } - let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props(); + let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain, domainOnly = false }: Props = $props(); // Extract sender IP from first hop const senderIp = $derived( @@ -61,88 +62,94 @@ {/if} - - {#if receivedChain && receivedChain.length > 0} -
-

- Received from: {receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}]) -

-
- {/if} + {#if !domainOnly} + + {#if receivedChain && receivedChain.length > 0} +
+

+ Received from: {receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}]) +

+
+ {/if} - - + + - - - -
- - -
-
-

- Return-Path Domain: {dnsResults.rp_domain || dnsResults.from_domain} -

- {#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)} - Differs from From domain - - - See domain alignment - - {:else} - Same as From domain - {/if} -
-
- - - {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} - + + +
+ + +
+
+

+ Return-Path Domain: {dnsResults.rp_domain || dnsResults.from_domain} +

+ {#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)} + Differs from From domain + + + See domain alignment + + {:else} + Same as From domain + {/if} +
+
+ + + {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} + + {/if} {/if} -
+ {#if !domainOnly} +
- -
-

- From Domain: {dnsResults.from_domain} -

- {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} - Differs from Return-Path domain - {/if} -
- - - {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} - + +
+

+ From Domain: {dnsResults.from_domain} +

+ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} + Differs from Return-Path domain + {/if} +
{/if} - - + + {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} + + {/if} - - + {#if !domainOnly} + + + {/if} - - + + + + + {/if} diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index dadab9e..fd4f3c9 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -15,3 +15,11 @@ export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; +export { default as GradeDisplay } from "./GradeDisplay.svelte"; +export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte"; +export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; +export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte"; +export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte"; +export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; +export { default as Logo } from "./Logo.svelte"; +export { default as EmailPathCard } from "./EmailPathCard.svelte"; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 765b03d..2cf556b 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -233,6 +233,13 @@ {/if} + + diff --git a/web/src/routes/domain/+page.svelte b/web/src/routes/domain/+page.svelte new file mode 100644 index 0000000..fe51876 --- /dev/null +++ b/web/src/routes/domain/+page.svelte @@ -0,0 +1,176 @@ + + + + Domain Test - happyDeliver + + +
+
+
+ +
+

+ + Test Domain Configuration +

+

+ Check your domain's email DNS records (MX, SPF, DMARC, BIMI) without sending an + email. +

+
+ + +
+
+

Enter Domain Name

+
+ + + + + +
+ + {#if error} + + {/if} + + + + Enter a domain name like "example.com" or "mail.example.org" + +
+
+ + +
+
+
+
+

+ + What's Checked +

+
    +
  • MX Records
  • +
  • SPF Records
  • +
  • DMARC Policy
  • +
  • BIMI Support
  • +
  • + Disposable Domain Check +
  • +
+
+
+
+ +
+
+
+

+ + Need More? +

+

+ For complete email deliverability analysis including: +

+
    +
  • + DKIM Verification +
  • +
  • + Content & Header Analysis +
  • +
  • + Spam Scoring +
  • +
  • + Blacklist Checks +
  • +
+ + + Send Test Email + +
+
+
+
+
+
+
+ + diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte new file mode 100644 index 0000000..7ce9ee4 --- /dev/null +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -0,0 +1,181 @@ + + + + {domain} - Domain Test - happyDeliver + + +
+
+
+ +
+
+

+ + Domain Analysis +

+ + + Test Another Domain + +
+
+ + {#if loading} + +
+
+
+ Loading... +
+

Analyzing {domain}...

+

Checking DNS records and configuration

+
+
+ {:else if error} + +
+
+ +

Analysis Failed

+

{error}

+ +
+
+ {:else if result} + +
+ +
+
+
+
+

+ {result.domain} +

+ {#if result.is_disposable} +
+ + Disposable Email Provider Detected +

+ This domain is a known temporary/disposable email service. + Emails from this domain may have lower deliverability. +

+
+ {:else} +

Domain Configuration Score

+ {/if} +
+
+
+ + DNS +
+
+
+
+
+ + + + + +
+
+

+ + Want Complete Email Analysis? +

+

+ This domain-only test checks DNS configuration. For comprehensive + deliverability testing including DKIM verification, content analysis, + spam scoring, and blacklist checks: +

+ + + Send a Test Email + +
+
+
+ {/if} +
+
+
+ +