Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
5d1dce038f WIP add dnssec test
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-18 13:38:31 +07:00
6ae2c2463d Use peterzen/goresolver as resolver 2025-11-17 11:00:36 +07:00
8 changed files with 375 additions and 16 deletions

View file

@ -942,6 +942,10 @@ components:
$ref: '#/components/schemas/DMARCRecord'
bimi_record:
$ref: '#/components/schemas/BIMIRecord'
dnssec_enabled:
type: boolean
description: Whether the From domain has DNSSEC enabled with valid chain of trust
example: true
ptr_records:
type: array
items:

2
go.mod
View file

@ -47,6 +47,7 @@ require (
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/miekg/dns v1.1.4 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
@ -55,6 +56,7 @@ require (
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/peterzen/goresolver v1.0.2 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.56.0 // indirect
github.com/redis/go-redis/v9 v9.16.0 // indirect

8
go.sum
View file

@ -120,6 +120,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.4 h1:rCMZsU2ScVSYcAsOXgmC6+AKOK+6pmQTOcw03nfwYV0=
github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -154,6 +156,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/peterzen/goresolver v1.0.2 h1:UxRxk835Onz7Go4oPUsOptSmBlIvN/yJ2kv3Srr3hw4=
github.com/peterzen/goresolver v1.0.2/go.mod h1:LrWRiOeCYApgvR2OhpipNOeaE1yGfI+QQjpF0riJC8M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
@ -198,6 +202,7 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@ -207,6 +212,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@ -216,12 +222,14 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222171317-cd391775e71e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View file

@ -127,6 +127,12 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
// Check BIMI record (for From domain - branding is based on visible sender)
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
// Check DNSSEC status (for From domain)
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, fromDomain)
if err == nil {
results.DnssecEnabled = &dnssecEnabled
}
return results
}
@ -149,6 +155,12 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
// Check BIMI record with default selector
results.BimiRecord = d.checkBIMIRecord(domain, "default")
// Check DNSSEC status
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, domain)
if err == nil {
results.DnssecEnabled = &dnssecEnabled
}
return results
}
@ -204,11 +216,16 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string
score := 0
// DNSSEC: 10 points
if results.DnssecEnabled != nil && *results.DnssecEnabled {
score += 10
}
// PTR and Forward DNS: 20 points
score += 20 * d.calculatePTRScore(results, senderIP) / 100
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
score += 20 * d.calculateMXScore(results) / 100
// MX Records: 10 points (5 for From domain, 5 for Return-Path domain)
score += 10 * d.calculateMXScore(results) / 100
// SPF Records: 20 points
score += 20 * d.calculateSPFScore(results) / 100

View file

@ -23,7 +23,12 @@ package analyzer
import (
"context"
"fmt"
"net"
"strings"
"github.com/miekg/dns"
"github.com/peterzen/goresolver"
)
// DNSResolver defines the interface for DNS resolution operations.
@ -43,38 +48,190 @@ type DNSResolver interface {
// LookupHost looks up the given hostname using the local resolver.
// It returns a slice of that host's addresses (IPv4 and IPv6).
LookupHost(ctx context.Context, host string) ([]string, error)
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
// Returns true if the domain has DNSSEC configured and the chain of trust is valid.
IsDNSSECEnabled(ctx context.Context, domain string) (bool, error)
}
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver.
// StandardDNSResolver is the default DNS resolver implementation that uses goresolver with DNSSEC validation.
type StandardDNSResolver struct {
resolver *net.Resolver
resolver *goresolver.Resolver
}
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings.
// NewStandardDNSResolver creates a new StandardDNSResolver with DNSSEC validation support.
func NewStandardDNSResolver() DNSResolver {
// Pass /etc/resolv.conf to load default DNS configuration
resolver, err := goresolver.NewResolver("/etc/resolv.conf")
if err != nil {
panic(fmt.Sprintf("failed to initialize goresolver: %v", err))
}
return &StandardDNSResolver{
resolver: &net.Resolver{
PreferGo: true,
},
resolver: resolver,
}
}
// LookupMX implements DNSResolver.LookupMX using net.Resolver.
// LookupMX implements DNSResolver.LookupMX using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
return r.resolver.LookupMX(ctx, name)
// Ensure the name ends with a dot for DNS queries
queryName := name
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeMX)
if err != nil {
return nil, err
}
mxRecords := make([]*net.MX, 0, len(rrs))
for _, rr := range rrs {
if mx, ok := rr.(*dns.MX); ok {
mxRecords = append(mxRecords, &net.MX{
Host: strings.TrimSuffix(mx.Mx, "."),
Pref: mx.Preference,
})
}
}
if len(mxRecords) == 0 {
return nil, fmt.Errorf("no MX records found for %s", name)
}
return mxRecords, nil
}
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver.
// LookupTXT implements DNSResolver.LookupTXT using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return r.resolver.LookupTXT(ctx, name)
// Ensure the name ends with a dot for DNS queries
queryName := name
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeTXT)
if err != nil {
return nil, err
}
txtRecords := make([]string, 0, len(rrs))
for _, rr := range rrs {
if txt, ok := rr.(*dns.TXT); ok {
// Join all TXT strings (a single TXT record can have multiple strings)
txtRecords = append(txtRecords, strings.Join(txt.Txt, ""))
}
}
if len(txtRecords) == 0 {
return nil, fmt.Errorf("no TXT records found for %s", name)
}
return txtRecords, nil
}
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver.
// LookupAddr implements DNSResolver.LookupAddr using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return r.resolver.LookupAddr(ctx, addr)
// Convert IP address to reverse DNS name (e.g., 1.0.0.127.in-addr.arpa.)
arpa, err := dns.ReverseAddr(addr)
if err != nil {
return nil, fmt.Errorf("invalid IP address: %w", err)
}
rrs, err := r.resolver.StrictNSQuery(arpa, dns.TypePTR)
if err != nil {
return nil, err
}
ptrRecords := make([]string, 0, len(rrs))
for _, rr := range rrs {
if ptr, ok := rr.(*dns.PTR); ok {
ptrRecords = append(ptrRecords, strings.TrimSuffix(ptr.Ptr, "."))
}
}
if len(ptrRecords) == 0 {
return nil, fmt.Errorf("no PTR records found for %s", addr)
}
return ptrRecords, nil
}
// LookupHost implements DNSResolver.LookupHost using net.Resolver.
// LookupHost implements DNSResolver.LookupHost using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
return r.resolver.LookupHost(ctx, host)
// Ensure the host ends with a dot for DNS queries
queryName := host
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
var allAddrs []string
// Query A records (IPv4)
rrsA, errA := r.resolver.StrictNSQuery(queryName, dns.TypeA)
if errA == nil {
for _, rr := range rrsA {
if a, ok := rr.(*dns.A); ok {
allAddrs = append(allAddrs, a.A.String())
}
}
}
// Query AAAA records (IPv6)
rrsAAAA, errAAAA := r.resolver.StrictNSQuery(queryName, dns.TypeAAAA)
if errAAAA == nil {
for _, rr := range rrsAAAA {
if aaaa, ok := rr.(*dns.AAAA); ok {
allAddrs = append(allAddrs, aaaa.AAAA.String())
}
}
}
// Return error only if both queries failed
if errA != nil && errAAAA != nil {
return nil, fmt.Errorf("failed to resolve host: IPv4 error: %v, IPv6 error: %v", errA, errAAAA)
}
if len(allAddrs) == 0 {
return nil, fmt.Errorf("no A or AAAA records found for %s", host)
}
return allAddrs, nil
}
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
// It uses DNSSEC validation to ensure the chain of trust is valid.
// Returns true if DNSSEC is properly configured and validated, false otherwise.
func (r *StandardDNSResolver) IsDNSSECEnabled(ctx context.Context, domain string) (bool, error) {
// Ensure the domain ends with a dot for DNS queries
queryName := domain
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
// Query for DNSKEY records with DNSSEC validation
// If this succeeds, it means:
// 1. The domain has DNSKEY records (DNSSEC is configured)
// 2. The DNSSEC chain of trust is valid (validated by StrictNSQuery)
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeDNSKEY)
if err != nil {
// DNSSEC is not enabled or validation failed
return false, nil
}
// Check if we got any DNSKEY records
if len(rrs) == 0 {
return false, nil
}
// Verify we actually have DNSKEY records (not just any RR type)
hasDNSKEY := false
for _, rr := range rrs {
if _, ok := rr.(*dns.DNSKEY); ok {
hasDNSKEY = true
break
}
}
return hasDNSKEY, nil
}

View file

@ -0,0 +1,111 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"testing"
)
func TestIsDNSSECEnabled(t *testing.T) {
resolver := NewStandardDNSResolver()
ctx := context.Background()
tests := []struct {
name string
domain string
expectDNSSEC bool
}{
{
name: "ietf.org has DNSSEC",
domain: "ietf.org",
expectDNSSEC: true,
},
{
name: "google.com doesn't have DNSSEC",
domain: "google.com",
expectDNSSEC: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
enabled, err := resolver.IsDNSSECEnabled(ctx, tt.domain)
if err != nil {
t.Errorf("IsDNSSECEnabled() error = %v", err)
return
}
if enabled != tt.expectDNSSEC {
t.Errorf("IsDNSSECEnabled() for %s = %v, want %v", tt.domain, enabled, tt.expectDNSSEC)
} else {
// Log the result even if we're not validating
if enabled {
t.Logf("%s: DNSSEC is enabled ✅", tt.domain)
} else {
t.Logf("%s: DNSSEC is NOT enabled ⚠️", tt.domain)
}
}
})
}
}
func TestIsDNSSECEnabled_NonExistentDomain(t *testing.T) {
resolver := NewStandardDNSResolver()
ctx := context.Background()
// Test with a domain that doesn't exist
enabled, err := resolver.IsDNSSECEnabled(ctx, "this-domain-definitely-does-not-exist-12345.com")
if err != nil {
// Error is acceptable for non-existent domains
t.Logf("Non-existent domain returned error (expected): %v", err)
return
}
// If no error, DNSSEC should be disabled
if enabled {
t.Error("IsDNSSECEnabled() for non-existent domain should return false")
}
}
func TestIsDNSSECEnabled_WithTrailingDot(t *testing.T) {
resolver := NewStandardDNSResolver()
ctx := context.Background()
// Test that both formats work
domain1 := "cloudflare.com"
domain2 := "cloudflare.com."
enabled1, err1 := resolver.IsDNSSECEnabled(ctx, domain1)
if err1 != nil {
t.Errorf("IsDNSSECEnabled() without trailing dot error = %v", err1)
}
enabled2, err2 := resolver.IsDNSSECEnabled(ctx, domain2)
if err2 != nil {
t.Errorf("IsDNSSECEnabled() with trailing dot error = %v", err2)
}
if enabled1 != enabled2 {
t.Errorf("IsDNSSECEnabled() results differ: without dot = %v, with dot = %v", enabled1, enabled2)
}
}

View file

@ -10,6 +10,7 @@
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import DnssecDisplay from "./DnssecDisplay.svelte";
interface Props {
domainAlignment?: DomainAlignment;
@ -150,6 +151,9 @@
<!-- BIMI Record -->
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
<!-- DNSSEC -->
<DnssecDisplay dnssecEnabled={dnsResults.dnssec_enabled} domain={dnsResults.from_domain} />
{/if}
</div>
</div>

View file

@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
dnssecEnabled?: boolean;
domain?: string;
}
let { dnssecEnabled, domain }: Props = $props();
// DNSSEC is valid if it's explicitly enabled
const dnssecIsValid = $derived(dnssecEnabled === true);
</script>
{#if dnssecEnabled !== undefined}
<div class="card mb-4" id="dns-dnssec">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-shield-check={dnssecIsValid}
class:text-success={dnssecIsValid}
class:bi-shield-x={!dnssecIsValid}
class:text-warning={!dnssecIsValid}
></i>
DNSSEC
</h5>
<span class="badge bg-secondary">Security</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-3">
DNSSEC (DNS Security Extensions) adds cryptographic signatures to DNS records to verify
their authenticity and integrity. It protects against DNS spoofing and cache poisoning
attacks, ensuring that DNS responses haven't been tampered with.
</p>
{#if domain}
<div class="mb-2">
<strong>Domain:</strong> <code>{domain}</code>
</div>
{/if}
{#if dnssecIsValid}
<div class="alert alert-success mb-0">
<i class="bi bi-check-circle me-1"></i>
<strong>Enabled:</strong> DNSSEC is properly configured with a valid chain of trust.
This provides additional security and authenticity for your domain's DNS records.
</div>
{:else}
<div class="alert alert-warning mb-0">
<i class="bi bi-info-circle me-1"></i>
<strong>Not Enabled:</strong> DNSSEC is not configured for this domain. While not
required for email delivery, enabling DNSSEC provides additional security by protecting
against DNS-based attacks. Consider enabling DNSSEC through your domain registrar or
DNS provider.
</div>
{/if}
</div>
</div>
{/if}