SPF check return-path
This commit is contained in:
parent
f6a1ea73a2
commit
8ca4bed875
7 changed files with 210 additions and 83 deletions
|
|
@ -777,17 +777,26 @@ components:
|
||||||
DNSResults:
|
DNSResults:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- domain
|
- from_domain
|
||||||
properties:
|
properties:
|
||||||
domain:
|
from_domain:
|
||||||
type: string
|
type: string
|
||||||
description: Domain name
|
description: From Domain name
|
||||||
example: "example.com"
|
example: "example.com"
|
||||||
mx_records:
|
rp_domain:
|
||||||
|
type: string
|
||||||
|
description: Return Path Domain name
|
||||||
|
example: "example.com"
|
||||||
|
from_mx_records:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/MXRecord'
|
$ref: '#/components/schemas/MXRecord'
|
||||||
description: MX records for the domain
|
description: MX records for the From domain
|
||||||
|
rp_mx_records:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MXRecord'
|
||||||
|
description: MX records for the Return-Path domain
|
||||||
spf_records:
|
spf_records:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|
|
||||||
|
|
@ -54,24 +54,40 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
|
||||||
// AnalyzeDNS performs DNS validation for the email's domain
|
// AnalyzeDNS performs DNS validation for the email's domain
|
||||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults {
|
||||||
// Extract domain from From address
|
// Extract domain from From address
|
||||||
domain := d.extractDomain(email)
|
fromDomain := d.extractFromDomain(email)
|
||||||
if domain == "" {
|
if fromDomain == "" {
|
||||||
return &api.DNSResults{
|
return &api.DNSResults{
|
||||||
Errors: &[]string{"Unable to extract domain from email"},
|
Errors: &[]string{"Unable to extract domain from email"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results := &api.DNSResults{
|
results := &api.DNSResults{
|
||||||
Domain: domain,
|
FromDomain: fromDomain,
|
||||||
|
RpDomain: d.extractRPDomain(email),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check MX records
|
// Determine which domain to check SPF for (Return-Path domain)
|
||||||
results.MxRecords = d.checkMXRecords(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)
|
// Check MX records for From domain (where replies would go)
|
||||||
results.SpfRecords = d.checkSPFRecords(domain)
|
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)
|
// Check DKIM records (from authentication results)
|
||||||
|
// DKIM can be for any domain, but typically the From domain
|
||||||
if authResults != nil && authResults.Dkim != nil {
|
if authResults != nil && authResults.Dkim != nil {
|
||||||
for _, dkim := range *authResults.Dkim {
|
for _, dkim := range *authResults.Dkim {
|
||||||
if dkim.Domain != nil && dkim.Selector != nil {
|
if dkim.Domain != nil && dkim.Selector != nil {
|
||||||
|
|
@ -86,17 +102,18 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check DMARC record
|
// Check DMARC record (for From domain - DMARC protects the visible sender)
|
||||||
results.DmarcRecord = d.checkDMARCRecord(domain)
|
// DMARC validates alignment between SPF/DKIM and the From domain
|
||||||
|
results.DmarcRecord = d.checkDMARCRecord(fromDomain)
|
||||||
|
|
||||||
// Check BIMI record (using default selector)
|
// Check BIMI record (for From domain - branding is based on visible sender)
|
||||||
results.BimiRecord = d.checkBIMIRecord(domain, "default")
|
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDomain extracts the domain from the email's From address
|
// extractFromDomain extracts the domain from the email's From address
|
||||||
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
|
func (d *DNSAnalyzer) extractFromDomain(email *EmailMessage) string {
|
||||||
if email.From != nil && email.From.Address != "" {
|
if email.From != nil && email.From.Address != "" {
|
||||||
parts := strings.Split(email.From.Address, "@")
|
parts := strings.Split(email.From.Address, "@")
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
|
|
@ -106,6 +123,17 @@ func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
|
||||||
return ""
|
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
|
// checkMXRecords looks up MX records for a domain
|
||||||
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
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
|
// 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
|
// Having valid MX records is critical for email deliverability
|
||||||
if results.MxRecords != nil && len(*results.MxRecords) > 0 {
|
// From domain MX records (10 points) - needed for replies
|
||||||
hasValidMX := false
|
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||||
for _, mx := range *results.MxRecords {
|
hasValidFromMX := false
|
||||||
|
for _, mx := range *results.FromMxRecords {
|
||||||
if mx.Valid {
|
if mx.Valid {
|
||||||
hasValidMX = true
|
hasValidFromMX = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasValidMX {
|
if hasValidFromMX {
|
||||||
score += 20
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,9 @@ func TestExtractDomain(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := analyzer.extractDomain(email)
|
domain := analyzer.extractFromDomain(email)
|
||||||
if domain != tt.expectedDomain {
|
if domain != tt.expectedDomain {
|
||||||
t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
|
t.Errorf("extractFromDomain() = %q, want %q", domain, tt.expectedDomain)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
||||||
maxGrade := 6
|
maxGrade := 6
|
||||||
headers := *analysis.Headers
|
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"}
|
requiredHeaders := []string{"from", "date", "message-id"}
|
||||||
requiredCount := len(requiredHeaders)
|
requiredCount := len(requiredHeaders)
|
||||||
presentRequired := 0
|
presentRequired := 0
|
||||||
|
|
@ -58,13 +65,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
||||||
}
|
}
|
||||||
|
|
||||||
if presentRequired == requiredCount {
|
if presentRequired == requiredCount {
|
||||||
score += 40
|
score += 30
|
||||||
} else {
|
} else {
|
||||||
score += int(40 * (float32(presentRequired) / float32(requiredCount)))
|
score += int(30 * (float32(presentRequired) / float32(requiredCount)))
|
||||||
maxGrade = 1
|
maxGrade = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check recommended headers (30 points)
|
// Check recommended headers (20 points)
|
||||||
recommendedHeaders := []string{"subject", "to"}
|
recommendedHeaders := []string{"subject", "to"}
|
||||||
|
|
||||||
// Add reply-to when from is a no-reply address
|
// Add reply-to when from is a no-reply address
|
||||||
|
|
@ -80,7 +87,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
||||||
presentRecommended++
|
presentRecommended++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
score += presentRecommended * 30 / recommendedCount
|
score += presentRecommended * 20 / recommendedCount
|
||||||
|
|
||||||
if presentRecommended < recommendedCount {
|
if presentRecommended < recommendedCount {
|
||||||
maxGrade -= 1
|
maxGrade -= 1
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import type { DNSResults } from "$lib/api/types.gen";
|
import type { DNSResults } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dnsResults?: DNSResults;
|
dnsResults?: DNSResults;
|
||||||
|
|
@ -35,10 +36,6 @@
|
||||||
{#if !dnsResults}
|
{#if !dnsResults}
|
||||||
<p class="text-muted mb-0">No DNS results available</p>
|
<p class="text-muted mb-0">No DNS results available</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Domain:</strong> <code>{dnsResults.domain}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if dnsResults.errors && dnsResults.errors.length > 0}
|
{#if dnsResults.errors && dnsResults.errors.length > 0}
|
||||||
<div class="alert alert-warning mb-3">
|
<div class="alert alert-warning mb-3">
|
||||||
<strong>Errors:</strong>
|
<strong>Errors:</strong>
|
||||||
|
|
@ -50,50 +47,36 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- MX Records -->
|
<!-- Return-Path Domain Section -->
|
||||||
{#if dnsResults.mx_records && dnsResults.mx_records.length > 0}
|
<div class="mb-3">
|
||||||
<div class="mb-4">
|
<strong>Return-Path Domain:</strong> <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||||
<h5 class="text-muted mb-2">
|
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||||
<span class="badge bg-secondary">MX</span> Mail Exchange Records
|
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Different from From domain</span>
|
||||||
</h5>
|
<small>
|
||||||
<div class="table-responsive">
|
<i class="bi bi-chevron-right"></i>
|
||||||
<table class="table table-sm table-bordered">
|
<a href="#domain-alignment">See domain alignment</a>
|
||||||
<thead>
|
</small>
|
||||||
<tr>
|
|
||||||
<th>Priority</th>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each dnsResults.mx_records as mx}
|
|
||||||
<tr>
|
|
||||||
<td>{mx.priority}</td>
|
|
||||||
<td><code>{mx.host}</code></td>
|
|
||||||
<td>
|
|
||||||
{#if mx.valid}
|
|
||||||
<span class="badge bg-success">Valid</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge bg-danger">Invalid</span>
|
<span class="badge bg-success ms-2">Same as From domain</span>
|
||||||
{#if mx.error}
|
|
||||||
<br><small class="text-danger">{mx.error}</small>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MX Records for Return-Path Domain -->
|
||||||
|
{#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0}
|
||||||
|
<MxRecordsDisplay
|
||||||
|
mxRecords={dnsResults.rp_mx_records}
|
||||||
|
title="Mail Exchange Records for Return-Path Domain"
|
||||||
|
description="These MX records handle bounce messages and non-delivery reports."
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- SPF Records -->
|
<!-- SPF Records (for Return-Path Domain) -->
|
||||||
{#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
|
{#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h5 class="text-muted mb-2">
|
<h5 class="text-muted mb-2">
|
||||||
<span class="badge bg-secondary">SPF</span> Sender Policy Framework
|
<span class="badge bg-secondary">SPF</span> Sender Policy Framework
|
||||||
</h5>
|
</h5>
|
||||||
|
<p class="small text-muted mb-2">SPF validates the Return-Path (envelope sender) domain.</p>
|
||||||
{#each dnsResults.spf_records as spf, index}
|
{#each dnsResults.spf_records as spf, index}
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -131,6 +114,25 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- From Domain Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>From Domain:</strong> <code>{dnsResults.from_domain}</code>
|
||||||
|
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||||
|
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Different from Return-Path domain</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MX Records for From Domain -->
|
||||||
|
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
|
||||||
|
<MxRecordsDisplay
|
||||||
|
mxRecords={dnsResults.from_mx_records}
|
||||||
|
title="Mail Exchange Records for From Domain"
|
||||||
|
description="These MX records handle replies to emails sent from this domain."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- DKIM Records -->
|
<!-- DKIM Records -->
|
||||||
{#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0}
|
{#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,18 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if headerAnalysis.domain_alignment}
|
{#if headerAnalysis.domain_alignment}
|
||||||
<div class="mb-3">
|
<div class="mb-3" id="domain-alignment">
|
||||||
<h6>Domain Alignment</h6>
|
<h6>Domain Alignment</h6>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted">Aligned</small>
|
||||||
|
<div>
|
||||||
|
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'} me-1"></i>
|
||||||
|
<strong>{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<small class="text-muted">From Domain</small>
|
<small class="text-muted">From Domain</small>
|
||||||
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
|
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
|
||||||
|
|
@ -69,13 +76,6 @@
|
||||||
<small class="text-muted">Return-Path Domain</small>
|
<small class="text-muted">Return-Path Domain</small>
|
||||||
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
|
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
|
||||||
<small class="text-muted">Aligned</small>
|
|
||||||
<div>
|
|
||||||
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'} me-1"></i>
|
|
||||||
{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
|
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
|
||||||
49
web/src/lib/components/MxRecordsDisplay.svelte
Normal file
49
web/src/lib/components/MxRecordsDisplay.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MXRecord } from "$lib/api/types.gen";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mxRecords: MXRecord[];
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mxRecords, title, description }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5 class="text-muted mb-2">
|
||||||
|
<span class="badge bg-secondary">MX</span> {title}
|
||||||
|
</h5>
|
||||||
|
{#if description}
|
||||||
|
<p class="small text-muted mb-2">{description}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each mxRecords as mx}
|
||||||
|
<tr>
|
||||||
|
<td>{mx.priority}</td>
|
||||||
|
<td><code>{mx.host}</code></td>
|
||||||
|
<td>
|
||||||
|
{#if mx.valid}
|
||||||
|
<span class="badge bg-success">Valid</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge bg-danger">Invalid</span>
|
||||||
|
{#if mx.error}
|
||||||
|
<br><small class="text-danger">{mx.error}</small>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue