This commit is contained in:
parent
7294193101
commit
9bf9d89695
6 changed files with 201 additions and 74 deletions
|
|
@ -54,24 +54,40 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
|
|||
// AnalyzeDNS performs DNS validation for the email's domain
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults {
|
||||
// Extract domain from From address
|
||||
domain := d.extractDomain(email)
|
||||
if domain == "" {
|
||||
fromDomain := d.extractFromDomain(email)
|
||||
if fromDomain == "" {
|
||||
return &api.DNSResults{
|
||||
Errors: &[]string{"Unable to extract domain from email"},
|
||||
}
|
||||
}
|
||||
|
||||
results := &api.DNSResults{
|
||||
Domain: domain,
|
||||
FromDomain: fromDomain,
|
||||
RpDomain: d.extractRPDomain(email),
|
||||
}
|
||||
|
||||
// Check MX records
|
||||
results.MxRecords = d.checkMXRecords(domain)
|
||||
// Determine which domain to check SPF for (Return-Path domain)
|
||||
// SPF validates the envelope sender (Return-Path), not the From header
|
||||
spfDomain := fromDomain
|
||||
if results.RpDomain != nil {
|
||||
spfDomain = *results.RpDomain
|
||||
}
|
||||
|
||||
// Check SPF records (including includes)
|
||||
results.SpfRecords = d.checkSPFRecords(domain)
|
||||
// Check MX records for From domain (where replies would go)
|
||||
results.FromMxRecords = d.checkMXRecords(fromDomain)
|
||||
|
||||
// Check MX records for Return-Path domain (where bounces would go)
|
||||
// Only check if Return-Path domain is different from From domain
|
||||
if results.RpDomain != nil && *results.RpDomain != fromDomain {
|
||||
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
|
||||
}
|
||||
|
||||
// Check SPF records (for Return-Path domain - this is the envelope sender)
|
||||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||
|
||||
// Check DKIM records (from authentication results)
|
||||
// DKIM can be for any domain, but typically the From domain
|
||||
if authResults != nil && authResults.Dkim != nil {
|
||||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && dkim.Selector != nil {
|
||||
|
|
@ -86,17 +102,18 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
|
|||
}
|
||||
}
|
||||
|
||||
// Check DMARC record
|
||||
results.DmarcRecord = d.checkDMARCRecord(domain)
|
||||
// Check DMARC record (for From domain - DMARC protects the visible sender)
|
||||
// DMARC validates alignment between SPF/DKIM and the From domain
|
||||
results.DmarcRecord = d.checkDMARCRecord(fromDomain)
|
||||
|
||||
// Check BIMI record (using default selector)
|
||||
results.BimiRecord = d.checkBIMIRecord(domain, "default")
|
||||
// Check BIMI record (for From domain - branding is based on visible sender)
|
||||
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// extractDomain extracts the domain from the email's From address
|
||||
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
|
||||
// extractFromDomain extracts the domain from the email's From address
|
||||
func (d *DNSAnalyzer) extractFromDomain(email *EmailMessage) string {
|
||||
if email.From != nil && email.From.Address != "" {
|
||||
parts := strings.Split(email.From.Address, "@")
|
||||
if len(parts) == 2 {
|
||||
|
|
@ -106,6 +123,17 @@ func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// extractRPDomain extracts the domain from the email's Return-Path address
|
||||
func (d *DNSAnalyzer) extractRPDomain(email *EmailMessage) *string {
|
||||
if email.ReturnPath != "" {
|
||||
parts := strings.Split(email.ReturnPath, "@")
|
||||
if len(parts) == 2 {
|
||||
return api.PtrTo(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(parts[1])), ">"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkMXRecords looks up MX records for a domain
|
||||
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
|
|
@ -529,18 +557,50 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) {
|
|||
|
||||
// TODO: 20 points for correct PTR and A/AAAA
|
||||
|
||||
// MX Records: 20 points
|
||||
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
|
||||
// Having valid MX records is critical for email deliverability
|
||||
if results.MxRecords != nil && len(*results.MxRecords) > 0 {
|
||||
hasValidMX := false
|
||||
for _, mx := range *results.MxRecords {
|
||||
// From domain MX records (10 points) - needed for replies
|
||||
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||
hasValidFromMX := false
|
||||
for _, mx := range *results.FromMxRecords {
|
||||
if mx.Valid {
|
||||
hasValidMX = true
|
||||
hasValidFromMX = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidMX {
|
||||
score += 20
|
||||
if hasValidFromMX {
|
||||
score += 10
|
||||
}
|
||||
}
|
||||
|
||||
// Return-Path domain MX records (10 points) - needed for bounces
|
||||
if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 {
|
||||
hasValidRpMX := false
|
||||
for _, mx := range *results.RpMxRecords {
|
||||
if mx.Valid {
|
||||
hasValidRpMX = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidRpMX {
|
||||
score += 10
|
||||
}
|
||||
} else if results.RpDomain != nil && *results.RpDomain != results.FromDomain {
|
||||
// If Return-Path domain is different but has no MX records, it's a problem
|
||||
// Don't deduct points if RP domain is same as From domain (already checked)
|
||||
} else {
|
||||
// If Return-Path is same as From domain, give full 10 points for RP MX
|
||||
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||
hasValidFromMX := false
|
||||
for _, mx := range *results.FromMxRecords {
|
||||
if mx.Valid {
|
||||
hasValidFromMX = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidFromMX {
|
||||
score += 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -560,8 +620,8 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) {
|
|||
// Softfail - moderate penalty
|
||||
score -= 5
|
||||
} else if strings.HasSuffix(*mainSPF.Record, " +all") ||
|
||||
strings.HasSuffix(*mainSPF.Record, " ?all") ||
|
||||
strings.HasSuffix(*mainSPF.Record, " all") {
|
||||
strings.HasSuffix(*mainSPF.Record, " ?all") ||
|
||||
strings.HasSuffix(*mainSPF.Record, " all") {
|
||||
// Pass/neutral - severe penalty
|
||||
score -= 10
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
maxGrade := 6
|
||||
headers := *analysis.Headers
|
||||
|
||||
// Check required headers (RFC 5322) - 40 points
|
||||
// RP and From alignment (20 points)
|
||||
if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned {
|
||||
score += 20
|
||||
} else {
|
||||
maxGrade -= 2
|
||||
}
|
||||
|
||||
// Check required headers (RFC 5322) - 30 points
|
||||
requiredHeaders := []string{"from", "date", "message-id"}
|
||||
requiredCount := len(requiredHeaders)
|
||||
presentRequired := 0
|
||||
|
|
@ -58,13 +65,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
}
|
||||
|
||||
if presentRequired == requiredCount {
|
||||
score += 40
|
||||
score += 30
|
||||
} else {
|
||||
score += int(40 * (float32(presentRequired) / float32(requiredCount)))
|
||||
score += int(30 * (float32(presentRequired) / float32(requiredCount)))
|
||||
maxGrade = 1
|
||||
}
|
||||
|
||||
// Check recommended headers (30 points)
|
||||
// Check recommended headers (20 points)
|
||||
recommendedHeaders := []string{"subject", "to"}
|
||||
|
||||
// Add reply-to when from is a no-reply address
|
||||
|
|
@ -80,7 +87,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
presentRecommended++
|
||||
}
|
||||
}
|
||||
score += presentRecommended * 30 / recommendedCount
|
||||
score += presentRecommended * 20 / recommendedCount
|
||||
|
||||
if presentRecommended < recommendedCount {
|
||||
maxGrade -= 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue