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} -
{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])
- {receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])
+ {dnsResults.rp_domain || dnsResults.from_domain}
- {dnsResults.rp_domain || dnsResults.from_domain}
+ {dnsResults.from_domain}
- {dnsResults.from_domain}
+ + Check your domain's email DNS records (MX, SPF, DMARC, BIMI) without sending an + email. +
++ For complete email deliverability analysis including: +
+Checking DNS records and configuration
+{error}
+ ++ This domain is a known temporary/disposable email service. + Emails from this domain may have lower deliverability. +
+Domain Configuration Score
+ {/if} ++ 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 + +