diff --git a/api/openapi.yaml b/api/openapi.yaml index 8dd1376..ce39bdd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -819,6 +819,18 @@ components: $ref: '#/components/schemas/DMARCRecord' bimi_record: $ref: '#/components/schemas/BIMIRecord' + ptr_records: + type: array + items: + type: string + description: PTR (reverse DNS) records for the sender IP address + example: ["mail.example.com", "smtp.example.com"] + ptr_forward_records: + type: array + items: + type: string + description: A or AAAA records resolved from the PTR hostnames (forward confirmation) + example: ["192.0.2.1", "2001:db8::1"] errors: type: array items: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index ee4d7d3..542d704 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -73,6 +73,22 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic spfDomain = *results.RpDomain } + // Store sender IP for later use in scoring + var senderIP string + if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 { + firstHop := (*headersResults.ReceivedChain)[0] + if firstHop.Ip != nil && *firstHop.Ip != "" { + senderIP = *firstHop.Ip + ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP) + if len(ptrRecords) > 0 { + results.PtrRecords = &ptrRecords + } + if len(forwardRecords) > 0 { + results.PtrForwardRecords = &forwardRecords + } + } + } + // Check MX records for From domain (where replies would go) results.FromMxRecords = d.checkMXRecords(fromDomain) @@ -613,16 +629,78 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool { return true } +// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) +// Returns PTR hostnames and their corresponding forward-resolved IPs +func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + // Perform reverse DNS lookup (PTR) + ptrNames, err := d.resolver.LookupAddr(ctx, ip) + if err != nil || len(ptrNames) == 0 { + return nil, nil + } + + var forwardIPs []string + seenIPs := make(map[string]bool) + + // For each PTR record, perform forward DNS lookup (A/AAAA) + for _, ptrName := range ptrNames { + // Look up A records + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + aRecords, err := d.resolver.LookupHost(ctx, ptrName) + cancel() + + if err == nil { + for _, forwardIP := range aRecords { + if !seenIPs[forwardIP] { + forwardIPs = append(forwardIPs, forwardIP) + seenIPs[forwardIP] = true + } + } + } + } + + return ptrNames, forwardIPs +} + // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better -func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { +// senderIP is the original sender IP address used for FCrDNS verification +func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) { if results == nil { return 0, "" } score := 0 - // TODO: 20 points for correct PTR and A/AAAA + // PTR and Forward DNS: 20 points + // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability + if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { + // 10 points for having PTR records + score += 10 + + if len(*results.PtrRecords) > 1 { + // Penalty has it's bad to have multiple PTR records + score -= 3 + } + + // Additional 10 points for forward-confirmed reverse DNS (FCrDNS) + // This means the PTR hostname resolves back to IPs that include the original sender IP + if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { + // Verify that the sender IP is in the list of forward-resolved IPs + fcrDnsValid := false + for _, forwardIP := range *results.PtrForwardRecords { + if forwardIP == senderIP { + fcrDnsValid = true + break + } + } + if fcrDnsValid { + score += 10 + } + } + } // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) // Having valid MX records is critical for email deliverability diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 6848a7d..bd6b866 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -98,7 +98,15 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu dnsScore := 0 var dnsGrade string if results.DNS != nil { - dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS) + // Extract sender IP from received chain for FCrDNS verification + var senderIP string + if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 { + firstHop := (*results.Headers.ReceivedChain)[0] + if firstHop.Ip != nil { + senderIP = *firstHop.Ip + } + } + dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP) } authScore := 0 @@ -178,6 +186,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu // Calculate overall score as mean of all category scores categoryScores := []int{ + report.Summary.DnsScore, report.Summary.AuthenticationScore, report.Summary.BlacklistScore, report.Summary.ContentScore, diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index a1ee24d..647a1d2 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,5 +1,5 @@
@@ -51,6 +59,27 @@
{/if} + + {#if receivedChain && receivedChain.length > 0} +
+

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

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

diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte new file mode 100644 index 0000000..77ce6c8 --- /dev/null +++ b/web/src/lib/components/PtrForwardRecordsDisplay.svelte @@ -0,0 +1,103 @@ + + +{#if ptrRecords && ptrRecords.length > 0} +
+
+
+ + Forward-Confirmed Reverse DNS +
+ FCrDNS +
+
+

+ Forward-confirmed reverse DNS (FCrDNS) verifies that the PTR hostname resolves back + to the original sender IP. This double-check helps establish sender legitimacy. +

+ {#if senderIp} +
+ Original Sender IP: {senderIp} +
+ {/if} +
+ {#if hasForwardRecords} +
+
+
+ PTR Hostname(s): + {#each ptrRecords as ptr} +
+ {ptr} +
+ {/each} +
+
+ Forward Resolution (A/AAAA): + {#each ptrForwardRecords as ip} +
+ {#if senderIp && ip === senderIp} + Match + {:else} + Different + {/if} + {ip} +
+ {/each} +
+ {#if fcrDnsIsValid} +
+ + Success: Forward-confirmed reverse DNS is properly configured. + The PTR hostname resolves back to the sender IP. +
+ {:else} +
+ + Warning: The PTR hostname does not resolve back to the sender + IP. This may impact deliverability. +
+ {/if} +
+
+ {:else} +
+
+
+ + Error: PTR hostname(s) found but could not resolve to any IP + addresses. Check your DNS configuration. +
+
+
+ {/if} +
+{/if} diff --git a/web/src/lib/components/PtrRecordsDisplay.svelte b/web/src/lib/components/PtrRecordsDisplay.svelte new file mode 100644 index 0000000..4ba7a81 --- /dev/null +++ b/web/src/lib/components/PtrRecordsDisplay.svelte @@ -0,0 +1,85 @@ + + +{#if ptrRecords && ptrRecords.length > 0} +
+
+
+ + Reverse DNS +
+ PTR +
+
+

+ PTR records (reverse DNS) map IP addresses back to hostnames. Having proper PTR + records is important as many mail servers verify that the sending IP has a valid + reverse DNS entry. +

+ {#if senderIp} +
+ Sender IP: {senderIp} +
+ {/if} +
+
+ {#each ptrRecords as ptr} +
+
+ Found + {ptr} +
+
+ {/each} + {#if ptrRecords.length > 1} +
+
+ + Warning: Multiple PTR records found. While not strictly an error, + having multiple PTR records can cause issues with some mail servers. It's recommended + to have exactly one PTR record per IP address. +
+
+ {/if} +
+
+{:else if senderIp} +
+
+
+ + Reverse DNS (PTR) +
+ PTR +
+
+

+ PTR records (reverse DNS) map IP addresses back to hostnames. Having proper PTR + records is important for email deliverability. +

+
+ Sender IP: {senderIp} +
+
+ + Error: No PTR records found for the sender IP. Contact your email service + provider to configure reverse DNS. +
+
+
+{/if} diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index a5b56ae..d3b7909 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -10,3 +10,5 @@ export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; export { default as BlacklistCard } from "./BlacklistCard.svelte"; export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; +export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; +export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 112ff10..5731485 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -146,6 +146,7 @@ dnsResults={report.dns_results} dnsGrade={report.summary?.dns_grade} dnsScore={report.summary?.dns_score} + receivedChain={report.header_analysis?.received_chain} />