diff --git a/README.md b/README.md index c76e248..a509d8c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration - **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers @@ -194,6 +194,8 @@ The deliverability score is calculated from 0 to 10 based on: - **Content (2 pts)**: HTML quality, links, images, unsubscribe - **Headers (1 pt)**: Required headers, MIME structure +**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor. + **Ratings:** - 9-10: Excellent - 7-8.9: Good diff --git a/api/openapi.yaml b/api/openapi.yaml index 467f62c..4f48869 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -353,6 +353,8 @@ components: $ref: '#/components/schemas/AuthResult' dmarc: $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' AuthResult: type: object @@ -420,7 +422,7 @@ components: example: "example.com" record_type: type: string - enum: [MX, SPF, DKIM, DMARC] + enum: [MX, SPF, DKIM, DMARC, BIMI] description: DNS record type example: "SPF" status: diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index 45df0a3..a0fd191 100644 --- a/internal/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -104,6 +104,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Dmarc = a.parseDMARCResult(part) } } + + // Parse BIMI + if strings.HasPrefix(part, "bimi=") { + if results.Bimi == nil { + results.Bimi = a.parseBIMIResult(part) + } + } } } @@ -214,6 +221,44 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { return result } +// parseBIMIResult parses BIMI result from Authentication-Results +// Example: bimi=pass header.d=example.com header.selector=default +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`bimi=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + // parseLegacySPF attempts to parse SPF from Received-SPF header func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { receivedSPF := email.Header.Get("Received-SPF") @@ -383,6 +428,12 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe }) } + // BIMI check (optional, informational only) + if results.Bimi != nil { + check := a.generateBIMICheck(results.Bimi) + checks = append(checks, check) + } + return checks } @@ -509,3 +560,38 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C return check } + +func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "BIMI (Brand Indicators)", + } + + switch bimi.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "BIMI validation passed" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") + case api.AuthResultResultFail: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "BIMI validation failed" + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") + } + + if bimi.Domain != nil { + details := fmt.Sprintf("Domain: %s", *bimi.Domain) + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go index 07c0346..b411386 100644 --- a/internal/analyzer/dns.go +++ b/internal/analyzer/dns.go @@ -58,6 +58,7 @@ type DNSResults struct { SPFRecord *SPFRecord DKIMRecords []DKIMRecord DMARCRecord *DMARCRecord + BIMIRecord *BIMIRecord Errors []string } @@ -93,6 +94,17 @@ type DMARCRecord struct { Error string } +// BIMIRecord represents a BIMI record +type BIMIRecord struct { + Selector string + Domain string + Record string + LogoURL string // URL to the brand logo (SVG) + VMCURL string // URL to Verified Mark Certificate (optional) + Valid bool + Error string +} + // AnalyzeDNS performs DNS validation for the email's domain func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { // Extract domain from From address @@ -128,6 +140,9 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // Check DMARC record results.DMARCRecord = d.checkDMARCRecord(domain) + // Check BIMI record (using default selector) + results.BIMIRecord = d.checkBIMIRecord(domain, "default") + return results } @@ -395,6 +410,89 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool { return true } +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), + } + } + + if len(txtRecords) == 0 { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: "No BIMI record found", + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Record: bimiRecord, + LogoURL: logoURL, + VMCURL: vmcURL, + Valid: false, + Error: "BIMI record appears malformed", + } + } + + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Record: bimiRecord, + LogoURL: logoURL, + VMCURL: vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} + // GenerateDNSChecks generates check results for DNS validation func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { var checks []api.Check @@ -421,6 +519,11 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) } + // BIMI record check (optional) + if results.BIMIRecord != nil { + checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) + } + return checks } @@ -564,3 +667,53 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { return check } + +// generateBIMICheck creates a check for BIMI records +func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { + check := api.Check{ + Category: api.Dns, + Name: "BIMI Record", + } + + if !bimi.Valid { + // BIMI is optional, so missing record is just informational + if bimi.Record == "" { + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "No BIMI record found (optional)" + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)") + } else { + // If record exists but is invalid + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error) + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)") + check.Details = &bimi.Record + } + } else { + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "Valid BIMI record found" + check.Severity = api.PtrTo(api.Info) + + // Build details with logo and VMC URLs + var detailsParts []string + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector)) + if bimi.LogoURL != "" { + detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL)) + } + if bimi.VMCURL != "" { + detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL)) + check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate") + } else { + check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust") + } + + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/dns_test.go b/internal/analyzer/dns_test.go index fe501d5..12a6bd0 100644 --- a/internal/analyzer/dns_test.go +++ b/internal/analyzer/dns_test.go @@ -631,3 +631,190 @@ func TestAnalyzeDNS_NoDomain(t *testing.T) { t.Error("Expected error when no domain can be extracted") } } + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestGenerateBIMICheck(t *testing.T) { + tests := []struct { + name string + bimi *BIMIRecord + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid BIMI with logo only", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1; l=https://example.com/logo.svg", + LogoURL: "https://example.com/logo.svg", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // BIMI doesn't contribute to score + }, + { + name: "Valid BIMI with VMC", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + LogoURL: "https://example.com/logo.svg", + VMCURL: "https://example.com/vmc.pem", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, + }, + { + name: "No BIMI record (optional)", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Valid: false, + Error: "No BIMI record found", + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + { + name: "Invalid BIMI record", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1", + Valid: false, + Error: "BIMI record appears malformed", + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateBIMICheck(tt.bimi) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + if check.Name != "BIMI Record" { + t.Errorf("Name = %q, want %q", check.Name, "BIMI Record") + } + + // Check details for valid BIMI with VMC + if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil { + if !strings.Contains(*check.Details, "VMC URL") { + t.Error("Details should contain VMC URL for valid BIMI with VMC") + } + } + }) + } +} diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index ab9a6f8..ebe1f1d 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -47,7 +47,7 @@