Compare commits
3 commits
f14209d4fa
...
1abd76b92d
| Author | SHA1 | Date | |
|---|---|---|---|
| 1abd76b92d | |||
| 3279ef205d | |||
| 456cf3f97a |
13 changed files with 21 additions and 655 deletions
|
|
@ -363,12 +363,6 @@ 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:
|
||||||
|
|
|
||||||
100
api/schemas.yaml
100
api/schemas.yaml
|
|
@ -1217,9 +1217,6 @@ 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
|
||||||
|
|
@ -1271,103 +1268,6 @@ 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
2
go.mod
|
|
@ -3,8 +3,6 @@ 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
4
go.sum
|
|
@ -1,7 +1,3 @@
|
||||||
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=
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -31,10 +30,8 @@ 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"
|
||||||
|
|
@ -53,17 +50,15 @@ type APIHandler struct {
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
config *config.Config
|
config *config.Config
|
||||||
analyzer EmailAnalyzer
|
analyzer EmailAnalyzer
|
||||||
blacklistProvider sdk.ObservationProvider
|
|
||||||
startTime time.Time
|
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, blacklistProvider sdk.ObservationProvider) *APIHandler {
|
func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler {
|
||||||
return &APIHandler{
|
return &APIHandler{
|
||||||
storage: store,
|
storage: store,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
analyzer: analyzer,
|
analyzer: analyzer,
|
||||||
blacklistProvider: blacklistProvider,
|
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +339,6 @@ 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)
|
||||||
|
|
@ -389,32 +383,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ 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"
|
||||||
|
|
@ -71,7 +70,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, blacklist.Provider())
|
handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
|
||||||
|
|
||||||
// Set up Gin router
|
// Set up Gin router
|
||||||
if os.Getenv("GIN_MODE") == "" {
|
if os.Getenv("GIN_MODE") == "" {
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -40,8 +40,6 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -75,18 +75,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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";
|
||||||
|
|
|
||||||
|
|
@ -161,10 +161,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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, DomainBlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
|
import { DnsRecordsCard, 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,44 +12,6 @@
|
||||||
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;
|
||||||
|
|
@ -112,9 +74,7 @@
|
||||||
<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">
|
<p class="text-muted mb-0">Checking DNS records and configuration</p>
|
||||||
Checking DNS records, configuration and domain reputation
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
|
|
@ -156,32 +116,15 @@
|
||||||
<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="col-md-6">
|
<div class="offset-md-3 col-md-3 text-center">
|
||||||
<div
|
|
||||||
class="d-flex justify-content-md-end justify-content-center gap-3"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === "light"}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== "light"}
|
||||||
>
|
>
|
||||||
<GradeDisplay
|
<GradeDisplay score={result.score} grade={result.grade} />
|
||||||
score={result.score}
|
|
||||||
grade={result.grade}
|
|
||||||
/>
|
|
||||||
<small class="text-muted d-block">DNS</small>
|
<small class="text-muted d-block">DNS</small>
|
||||||
</div>
|
</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 class="d-flex justify-content-end me-lg-5 mt-3">
|
<div class="d-flex justify-content-end me-lg-5 mt-3">
|
||||||
|
|
@ -201,119 +144,6 @@
|
||||||
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">
|
||||||
|
|
@ -322,9 +152,9 @@
|
||||||
Want Complete Email Analysis?
|
Want Complete Email Analysis?
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
This domain test checks DNS configuration and domain reputation. For
|
This domain-only test checks DNS configuration. For comprehensive
|
||||||
comprehensive deliverability testing including DKIM verification,
|
deliverability testing including DKIM verification, content
|
||||||
content analysis, spam scoring, and sending-IP blacklist checks:
|
analysis, spam scoring, and 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue