Implement BIMI checks
This commit is contained in:
parent
35ff54b2e1
commit
c1211a8ce1
7 changed files with 441 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
<div class="col-md-6">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check2 text-success me-2"></i> SPF, DKIM, DMARC
|
||||
<i class="bi bi-check2 text-success me-2"></i> SPF, DKIM, DMARC, BIMI
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check2 text-success me-2"></i> DNS Records
|
||||
|
|
|
|||
|
|
@ -26,13 +26,19 @@
|
|||
icon: "bi-shield-check",
|
||||
title: "Authentication",
|
||||
description:
|
||||
"SPF, DKIM, and DMARC validation with detailed results and recommendations.",
|
||||
"SPF, DKIM, DMARC, and BIMI validation with detailed results and recommendations.",
|
||||
variant: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-patch-check",
|
||||
title: "BIMI Support",
|
||||
description: "Brand Indicators for Message Identification - verify your brand logo configuration.",
|
||||
variant: "info" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-globe",
|
||||
title: "DNS Records",
|
||||
description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.",
|
||||
description: "Verify MX, SPF, DKIM, DMARC, and BIMI records are properly configured.",
|
||||
variant: "success" as const,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue