blacklist: add domain reputation check via checker-blacklist
Some checks reported errors
continuous-integration/drone/push Build was killed

Integrates the checker-blacklist module behind a new POST /blacklist/domain
endpoint that aggregates reputation/blocklist sources for a given domain,
plus a SvelteKit UI under /blacklist/domain mirroring the existing IP
blacklist flow. Per-source credentials (VirusTotal, Safe Browsing) are
exposed as CLI flags; free sources run unconditionally.

Closes: #96
This commit is contained in:
nemunaire 2026-06-04 18:38:45 +09:00
commit f14209d4fa
13 changed files with 655 additions and 21 deletions

View file

@ -363,6 +363,12 @@ components:
$ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest'
BlacklistCheckResponse: BlacklistCheckResponse:
$ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse'
DomainBlacklistResult:
$ref: './schemas.yaml#/components/schemas/DomainBlacklistResult'
DomainBlacklistSourceResult:
$ref: './schemas.yaml#/components/schemas/DomainBlacklistSourceResult'
DomainBlacklistEvidence:
$ref: './schemas.yaml#/components/schemas/DomainBlacklistEvidence'
TestSummary: TestSummary:
$ref: './schemas.yaml#/components/schemas/TestSummary' $ref: './schemas.yaml#/components/schemas/TestSummary'
TestListResponse: TestListResponse:

View file

@ -1217,6 +1217,9 @@ components:
example: "A" example: "A"
dns_results: dns_results:
$ref: '#/components/schemas/DNSResults' $ref: '#/components/schemas/DNSResults'
blacklist:
$ref: '#/components/schemas/DomainBlacklistResult'
description: Domain reputation/blacklist aggregation (omitted when the check could not be run)
BlacklistCheckRequest: BlacklistCheckRequest:
type: object type: object
@ -1268,6 +1271,103 @@ components:
$ref: '#/components/schemas/BlacklistCheck' $ref: '#/components/schemas/BlacklistCheck'
description: List of DNS whitelist check results (informational only) description: List of DNS whitelist check results (informational only)
DomainBlacklistResult:
type: object
required:
- registered_domain
- collected_at
- results
properties:
registered_domain:
type: string
description: eTLD+1 of the input domain
example: "example.com"
collected_at:
type: string
format: date-time
description: When the aggregation finished
score:
type: integer
minimum: 0
maximum: 100
description: Reputation score (0-100, higher is better). Omitted when the verdict is inconclusive (no usable source).
example: 100
grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade derived from the score. Omitted when the verdict is inconclusive.
example: "A+"
results:
type: array
items:
$ref: '#/components/schemas/DomainBlacklistSourceResult'
description: One entry per registered source (disabled sources included with enabled=false)
DomainBlacklistSourceResult:
type: object
required:
- source_id
- source_name
- enabled
- listed
properties:
source_id:
type: string
example: "quad9"
source_name:
type: string
example: "Quad9"
subject:
type: string
description: Per-zone identifier (DNSBL zones only)
enabled:
type: boolean
description: False when the source is disabled or missing credentials
listed:
type: boolean
description: Verdict from the source's Evaluate (false when disabled or errored)
blocked_query:
type: boolean
description: Resolver returned a block response (not a real listing)
severity:
type: string
description: Severity attached to the verdict (crit, warn, info, ok, or empty)
reasons:
type: array
items: { type: string }
evidence:
type: array
items:
$ref: '#/components/schemas/DomainBlacklistEvidence'
lookup_url:
type: string
removal_url:
type: string
reference:
type: string
error:
type: string
details:
type: object
additionalProperties: true
description: Source-specific structured data (free-form)
DomainBlacklistEvidence:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: string
status:
type: string
extra:
type: object
additionalProperties: { type: string }
TestSummary: TestSummary:
type: object type: object
required: required:

2
go.mod
View file

@ -3,6 +3,8 @@ module git.happydns.org/happyDeliver
go 1.25.0 go 1.25.0
require ( require (
git.happydns.org/checker-blacklist v0.4.0
git.happydns.org/checker-sdk-go v1.9.0
github.com/JGLTechnologies/gin-rate-limit v1.5.8 github.com/JGLTechnologies/gin-rate-limit v1.5.8
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.138.0 github.com/getkin/kin-openapi v0.138.0

4
go.sum
View file

@ -1,3 +1,7 @@
git.happydns.org/checker-blacklist v0.4.0 h1:mTOWz2tcMXGU2WFVM9VLxnSJ7mFXL2Lhq5NBq+lUU7g=
git.happydns.org/checker-blacklist v0.4.0/go.mod h1:huOwQWfAA+Wo+WbUbtyRIS/Y0eA+C3YrguGuJL+3qEE=
git.happydns.org/checker-sdk-go v1.9.0 h1:orBRymir+p6PMHVa4focryPKhTVWT7JAv6u9Ido5KF0=
git.happydns.org/checker-sdk-go v1.9.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=

View file

@ -22,6 +22,7 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
@ -30,8 +31,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/reputation"
"git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version" "git.happydns.org/happyDeliver/internal/version"
@ -47,19 +50,21 @@ type EmailAnalyzer interface {
// APIHandler implements the ServerInterface for handling API requests // APIHandler implements the ServerInterface for handling API requests
type APIHandler struct { type APIHandler struct {
storage storage.Storage storage storage.Storage
config *config.Config config *config.Config
analyzer EmailAnalyzer analyzer EmailAnalyzer
startTime time.Time blacklistProvider sdk.ObservationProvider
startTime time.Time
} }
// NewAPIHandler creates a new API handler // NewAPIHandler creates a new API handler
func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer, blacklistProvider sdk.ObservationProvider) *APIHandler {
return &APIHandler{ return &APIHandler{
storage: store, storage: store,
config: cfg, config: cfg,
analyzer: analyzer, analyzer: analyzer,
startTime: time.Now(), blacklistProvider: blacklistProvider,
startTime: time.Now(),
} }
} }
@ -339,6 +344,7 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
Score: score, Score: score,
Grade: responseGrade, Grade: responseGrade,
DnsResults: *dnsResults, DnsResults: *dnsResults,
Blacklist: h.runDomainBlacklist(c.Request.Context(), request.Domain),
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
@ -383,6 +389,32 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// runDomainBlacklist runs the checker-blacklist aggregation against a domain.
// It returns nil (and logs nothing fatal) when the check cannot be run, so the
// surrounding domain analysis still succeeds.
func (h *APIHandler) runDomainBlacklist(ctx context.Context, domain string) *model.DomainBlacklistResult {
opts := h.config.Analysis.Blacklist.AsCheckerOptions()
// "domain_name" is the option key the checker-blacklist provider reads
// (see checker/collect.go in the checker-blacklist module).
opts["domain_name"] = domain
// Cap the aggregation: sources run concurrently, each with its own
// timeouts; this is the host-side ceiling.
timeout := h.config.Analysis.HTTPTimeout
if timeout <= 0 {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
raw, err := h.blacklistProvider.Collect(ctx, opts)
if err != nil {
return nil
}
return reputation.FromObservation(raw)
}
// ListTests returns a paginated list of test summaries // ListTests returns a paginated list of test summaries
// (GET /tests) // (GET /tests)
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {

View file

@ -30,6 +30,7 @@ import (
ratelimit "github.com/JGLTechnologies/gin-rate-limit" ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
blacklist "git.happydns.org/checker-blacklist/checker"
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/lmtp"
@ -70,7 +71,7 @@ func RunServer(cfg *config.Config) error {
analyzerAdapter := analyzer.NewAPIAdapter(cfg) analyzerAdapter := analyzer.NewAPIAdapter(cfg)
// Create API handler // Create API handler
handler := api.NewAPIHandler(store, cfg, analyzerAdapter) handler := api.NewAPIHandler(store, cfg, analyzerAdapter, blacklist.Provider())
// Set up Gin router // Set up Gin router
if os.Getenv("GIN_MODE") == "" { if os.Getenv("GIN_MODE") == "" {

View file

@ -0,0 +1,40 @@
// 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 config
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// AsCheckerOptions returns the map that checker-blacklist sources read
// via stringOpt(). Empty values are omitted so sources that require a
// credential stay disabled rather than failing with an empty key.
func (b BlacklistConfig) AsCheckerOptions() sdk.CheckerOptions {
opts := sdk.CheckerOptions{}
if b.VirusTotalAPIKey != "" {
opts["virustotal_api_key"] = b.VirusTotalAPIKey
}
if b.SafeBrowsingAPIKey != "" {
opts["safebrowsing_api_key"] = b.SafeBrowsingAPIKey
}
return opts
}

View file

@ -40,6 +40,8 @@ func declareFlags(o *Config) {
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
flag.StringVar(&o.Analysis.Blacklist.VirusTotalAPIKey, "blacklist-virustotal-api-key", o.Analysis.Blacklist.VirusTotalAPIKey, "VirusTotal v3 API key for the domain blacklist checker")
flag.StringVar(&o.Analysis.Blacklist.SafeBrowsingAPIKey, "blacklist-safebrowsing-api-key", o.Analysis.Blacklist.SafeBrowsingAPIKey, "Google Safe Browsing API key for the domain blacklist checker")
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")

View file

@ -75,6 +75,18 @@ type AnalysisConfig struct {
DNSWLs []string DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one CheckAllIPs bool // Check all IPs found in headers, not just the first one
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
Blacklist BlacklistConfig
}
// BlacklistConfig holds per-source credentials/options for the
// domain-oriented checker-blacklist provider. Keys must match the
// option IDs declared by each source in the checker-blacklist module
// (see checker/virustotal.go, checker/safebrowsing.go, …). Free sources
// (Quad9, OISD, URLhaus, OpenPhish, Disconnect, Botvrij, …) need no
// configuration.
type BlacklistConfig struct {
VirusTotalAPIKey string
SafeBrowsingAPIKey string
} }
// DefaultConfig returns a configuration with sensible defaults // DefaultConfig returns a configuration with sensible defaults

View file

@ -0,0 +1,260 @@
<script lang="ts">
import type { DomainBlacklistSourceResult } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
results: DomainBlacklistSourceResult[];
}
let { results }: Props = $props();
// Paid sources that show a "configure API key" hint when disabled.
const paidSourceIds = new Set(["virustotal", "safebrowsing"]);
type Bucket = "listed" | "errored" | "clean" | "disabled";
function classify(r: DomainBlacklistSourceResult): Bucket {
if (!r.enabled) return "disabled";
if (r.error) return "errored";
if (r.listed) return "listed";
return "clean";
}
function severityRank(sev: string | undefined): number {
switch (sev) {
case "crit":
return 0;
case "warn":
return 1;
case "info":
return 2;
default:
return 3;
}
}
function bucketRank(b: Bucket): number {
switch (b) {
case "listed":
return 0;
case "errored":
return 1;
case "clean":
return 2;
case "disabled":
return 3;
}
}
let sorted = $derived(
[...results].sort((a, b) => {
const ba = classify(a);
const bb = classify(b);
if (ba !== bb) return bucketRank(ba) - bucketRank(bb);
if (ba === "listed") {
const r = severityRank(a.severity) - severityRank(b.severity);
if (r !== 0) return r;
}
return a.source_name.localeCompare(b.source_name);
}),
);
function statusLabel(r: DomainBlacklistSourceResult): string {
if (!r.enabled) return "Disabled";
if (r.error) return "Error";
if (r.listed) {
if (r.severity && r.severity !== "ok") {
return `Listed (${r.severity})`;
}
return "Listed";
}
return "Clean";
}
function statusBadgeClass(r: DomainBlacklistSourceResult): string {
if (!r.enabled) return "bg-secondary";
if (r.error) return "bg-dark";
if (r.listed) {
switch (r.severity) {
case "crit":
return "bg-danger";
case "warn":
return "bg-warning text-dark";
case "info":
return "bg-info text-dark";
default:
return "bg-danger";
}
}
return "bg-success";
}
let openRows = $state(new Set<string>());
function rowKey(r: DomainBlacklistSourceResult): string {
return `${r.source_id}::${r.subject ?? ""}`;
}
function toggle(key: string) {
const next = new Set(openRows);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
openRows = next;
}
function hasDetails(r: DomainBlacklistSourceResult): boolean {
return (r.reasons?.length ?? 0) > 1 || (r.evidence?.length ?? 0) > 0;
}
function firstReason(r: DomainBlacklistSourceResult): string {
if (r.error) return r.error;
if (r.reasons && r.reasons.length > 0) return r.reasons[0];
if (!r.enabled && paidSourceIds.has(r.source_id)) {
return "API key not configured by the operator";
}
if (!r.enabled) return "Source disabled";
return "—";
}
</script>
<div class="card shadow-sm mt-4" id="domain-blacklist-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0">
<i class="bi bi-shield-shaded me-2"></i>
Source Verdicts
</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col" class="text-nowrap">Status</th>
<th scope="col">Source</th>
<th scope="col">Detail</th>
<th scope="col" class="text-end text-nowrap">Links</th>
</tr>
</thead>
<tbody>
{#each sorted as r (rowKey(r))}
{@const key = rowKey(r)}
{@const open = openRows.has(key)}
{@const expandable = hasDetails(r)}
<tr class:text-muted={!r.enabled}>
<td class="text-nowrap">
<span class="badge {statusBadgeClass(r)}">{statusLabel(r)}</span>
</td>
<td>
<div class="fw-semibold">{r.source_name}</div>
<small class="text-muted">
<code>{r.source_id}</code>
{#if r.subject}
· <code>{r.subject}</code>
{/if}
</small>
</td>
<td>
<span class="detail-text">{firstReason(r)}</span>
{#if expandable}
<button
type="button"
class="btn btn-link btn-sm p-0 ms-1 align-baseline"
onclick={() => toggle(key)}
aria-expanded={open}
>
{open ? "Hide details" : "Show details"}
</button>
{/if}
</td>
<td class="text-end text-nowrap">
{#if r.lookup_url}
<a
href={r.lookup_url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-outline-secondary"
title="Open lookup page"
aria-label="Open lookup page"
>
<i class="bi bi-box-arrow-up-right"></i>
</a>
{/if}
</td>
</tr>
{#if expandable && open}
<tr class="detail-row">
<td></td>
<td colspan="3">
{#if r.reasons && r.reasons.length > 0}
<ul class="small mb-2">
{#each r.reasons as reason}
<li>{reason}</li>
{/each}
</ul>
{/if}
{#if r.evidence && r.evidence.length > 0}
<table
class="table table-sm table-bordered mb-0 evidence-table"
>
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Value</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{#each r.evidence as ev}
<tr>
<td class="text-nowrap">{ev.label}</td>
<td>
<code class="small">{ev.value}</code>
</td>
<td class="text-nowrap">
{#if ev.status}
<span
class="badge bg-light text-dark"
>{ev.status}</span
>
{:else}
<span class="text-muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if r.reference}
<p class="small text-muted mt-2 mb-0">
Reference: {r.reference}
</p>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
</div>
<style>
.detail-text {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
.detail-row td {
background-color: rgba(0, 0, 0, 0.025);
}
.evidence-table code {
word-break: break-all;
}
</style>

View file

@ -6,6 +6,7 @@ export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte"; export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as DomainBlacklistCard } from "./DomainBlacklistCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as EmailPathCard } from "./EmailPathCard.svelte"; export { default as EmailPathCard } from "./EmailPathCard.svelte";
export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; export { default as ErrorDisplay } from "./ErrorDisplay.svelte";

View file

@ -161,6 +161,10 @@
<i class="bi bi-envelope-plus me-1"></i> <i class="bi bi-envelope-plus me-1"></i>
Send Test Email Send Test Email
</a> </a>
<a href="/domain" class="btn btn-sm btn-outline-primary ms-1">
<i class="bi bi-shield-shaded me-1"></i>
Check a Domain
</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -4,7 +4,7 @@
import { testDomain } from "$lib/api"; import { testDomain } from "$lib/api";
import type { DomainTestResponse } from "$lib/api/types.gen"; import type { DomainTestResponse } from "$lib/api/types.gen";
import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components"; import { DnsRecordsCard, DomainBlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
let domain = $derived(page.params.domain); let domain = $derived(page.params.domain);
@ -12,6 +12,44 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let result = $state<DomainTestResponse | null>(null); let result = $state<DomainTestResponse | null>(null);
let blacklist = $derived(result?.blacklist ?? null);
let blacklistSummary = $derived.by(() => {
if (!blacklist) return null;
const enabled = blacklist.results.filter((r) => r.enabled);
const disabled = blacklist.results.length - enabled.length;
const errored = enabled.filter((r) => r.error).length;
const listed = enabled.filter((r) => r.listed);
const critical = listed.filter((r) => r.severity === "crit").length;
return {
total: blacklist.results.length,
enabled: enabled.length,
disabled,
errored,
listed: listed.length,
critical,
};
});
type Verdict = "danger" | "warn" | "inconclusive" | "ok";
let blacklistVerdict = $derived.by<Verdict | null>(() => {
const s = blacklistSummary;
if (!s) return null;
if (s.critical > 0) return "danger";
if (s.listed > 0) return "warn";
if (s.enabled > 0 && s.errored === s.enabled) return "inconclusive";
return "ok";
});
function formatCollectedAt(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
async function analyzeDomain() { async function analyzeDomain() {
loading = true; loading = true;
error = null; error = null;
@ -74,7 +112,9 @@
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
<h3 class="h5">Analyzing {domain}...</h3> <h3 class="h5">Analyzing {domain}...</h3>
<p class="text-muted mb-0">Checking DNS records and configuration</p> <p class="text-muted mb-0">
Checking DNS records, configuration and domain reputation
</p>
</div> </div>
</div> </div>
{:else if error} {:else if error}
@ -116,14 +156,31 @@
<p class="text-muted mb-0">Domain Configuration Score</p> <p class="text-muted mb-0">Domain Configuration Score</p>
{/if} {/if}
</div> </div>
<div class="offset-md-3 col-md-3 text-center"> <div class="col-md-6">
<div <div
class="p-2 rounded text-center summary-card" class="d-flex justify-content-md-end justify-content-center gap-3"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
> >
<GradeDisplay score={result.score} grade={result.grade} /> <div
<small class="text-muted d-block">DNS</small> class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
score={result.score}
grade={result.grade}
/>
<small class="text-muted d-block">DNS</small>
</div>
{#if blacklist}
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay grade={blacklist.grade ?? "?"} />
<small class="text-muted d-block">Reputation</small>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -144,6 +201,119 @@
domainOnly={true} domainOnly={true}
/> />
<!-- Domain Reputation / Blacklist -->
{#if blacklist && blacklistSummary}
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-7 text-center text-md-start mb-3 mb-md-0">
<h3 class="h5 mb-2">
<i class="bi bi-shield-shaded me-2"></i>
Domain Reputation
</h3>
{#if blacklist.registered_domain && blacklist.registered_domain !== result.domain}
<p class="text-muted small mb-2">
Registered domain:
<code>{blacklist.registered_domain}</code>
</p>
{/if}
{#if blacklistVerdict === "danger"}
<div class="alert alert-danger mb-0 d-inline-block">
<i class="bi bi-exclamation-octagon me-2"></i>
<strong
>Listed on {blacklistSummary.critical} high-severity
source{blacklistSummary.critical > 1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
This domain is reported by sources flagged
<em>critical</em>. Take action to delist.
</p>
</div>
{:else if blacklistVerdict === "warn"}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong
>Listed on {blacklistSummary.listed} source{blacklistSummary.listed >
1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
Listed without critical severity — review the
source verdicts below.
</p>
</div>
{:else if blacklistVerdict === "inconclusive"}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-question-octagon me-2"></i>
<strong>Inconclusive</strong>
<p class="mb-0 mt-1 small">
All enabled sources returned errors. Try again
later.
</p>
</div>
{:else}
<div class="alert alert-success mb-0 d-inline-block">
<i class="bi bi-check-circle me-2"></i>
<strong>No source reports this domain</strong>
<p class="mb-0 mt-1 small">
Clean across all {blacklistSummary.enabled} enabled
source{blacklistSummary.enabled > 1 ? "s" : ""}.
</p>
</div>
{/if}
</div>
<div class="col-md-5 text-center">
<div
class="p-3 rounded summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<div class="d-flex justify-content-around mb-2">
<div>
<div class="h4 mb-0">
{blacklistSummary.enabled}
</div>
<small class="text-muted">Enabled</small>
</div>
<div>
<div class="h4 mb-0 text-danger">
{blacklistSummary.listed}
</div>
<small class="text-muted">Listed</small>
</div>
<div>
<div class="h4 mb-0 text-secondary">
{blacklistSummary.disabled}
</div>
<small class="text-muted">Disabled</small>
</div>
</div>
{#if blacklistSummary.errored > 0}
<small class="text-muted d-block">
{blacklistSummary.errored} source{blacklistSummary.errored >
1
? "s"
: ""} errored
</small>
{/if}
<small class="text-muted d-block">
Collected {formatCollectedAt(
blacklist.collected_at,
)}
</small>
</div>
</div>
</div>
</div>
</div>
<DomainBlacklistCard results={blacklist.results} />
{/if}
<!-- Next Steps --> <!-- Next Steps -->
<div class="card shadow-sm border-primary mt-4"> <div class="card shadow-sm border-primary mt-4">
<div class="card-body"> <div class="card-body">
@ -152,9 +322,9 @@
Want Complete Email Analysis? Want Complete Email Analysis?
</h3> </h3>
<p class="mb-3"> <p class="mb-3">
This domain-only test checks DNS configuration. For comprehensive This domain test checks DNS configuration and domain reputation. For
deliverability testing including DKIM verification, content comprehensive deliverability testing including DKIM verification,
analysis, spam scoring, and blacklist checks: content analysis, spam scoring, and sending-IP blacklist checks:
</p> </p>
<a href="/" class="btn btn-primary"> <a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i> <i class="bi bi-envelope-plus me-2"></i>