Compare commits
4 commits
1abd76b92d
...
f14209d4fa
| Author | SHA1 | Date | |
|---|---|---|---|
| f14209d4fa | |||
| d53c1b1e00 | |||
| 8e7e56851b | |||
| a65b8084ee |
29 changed files with 1702 additions and 23 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
165
api/schemas.yaml
165
api/schemas.yaml
|
|
@ -434,6 +434,29 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Reverse DNS (PTR record) for the IP address
|
description: Reverse DNS (PTR record) for the IP address
|
||||||
example: "mail.example.com"
|
example: "mail.example.com"
|
||||||
|
tls:
|
||||||
|
$ref: '#/components/schemas/TLSInfo'
|
||||||
|
description: TLS details of the connection for this hop, if encrypted
|
||||||
|
|
||||||
|
TLSInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: TLS protocol version
|
||||||
|
example: "TLSv1.3"
|
||||||
|
cipher:
|
||||||
|
type: string
|
||||||
|
description: Cipher suite used
|
||||||
|
example: "TLS_AES_256_GCM_SHA384"
|
||||||
|
bits:
|
||||||
|
type: integer
|
||||||
|
description: Cipher strength in bits
|
||||||
|
example: 256
|
||||||
|
verified:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the peer certificate was verified/trusted
|
||||||
|
example: true
|
||||||
|
|
||||||
DKIMDomainInfo:
|
DKIMDomainInfo:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -540,6 +563,11 @@ components:
|
||||||
x_ptr:
|
x_ptr:
|
||||||
$ref: '#/components/schemas/XPtrResult'
|
$ref: '#/components/schemas/XPtrResult'
|
||||||
description: X-Ptr result (HELO hostname vs reverse DNS consistency check)
|
description: X-Ptr result (HELO hostname vs reverse DNS consistency check)
|
||||||
|
x_tls:
|
||||||
|
$ref: '#/components/schemas/AuthResult'
|
||||||
|
description: >-
|
||||||
|
Transport TLS encryption of the inbound connection (x-tls).
|
||||||
|
Synthesized from the inbound Received hop when no x-tls header is present.
|
||||||
|
|
||||||
AuthResult:
|
AuthResult:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -829,12 +857,49 @@ components:
|
||||||
helo_ptr_match:
|
helo_ptr_match:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive)
|
description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive)
|
||||||
|
return_ok:
|
||||||
|
$ref: '#/components/schemas/ReturnOK'
|
||||||
errors:
|
errors:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: DNS lookup errors
|
description: DNS lookup errors
|
||||||
|
|
||||||
|
ReturnOK:
|
||||||
|
type: object
|
||||||
|
description: Whether the sender domains can receive replies and bounces (MX, with A/AAAA fallback)
|
||||||
|
properties:
|
||||||
|
from:
|
||||||
|
$ref: '#/components/schemas/ReturnOKDomain'
|
||||||
|
return_path:
|
||||||
|
$ref: '#/components/schemas/ReturnOKDomain'
|
||||||
|
|
||||||
|
ReturnOKDomain:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- domain
|
||||||
|
- status
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: Domain that was evaluated
|
||||||
|
example: "example.com"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [pass, warn, fail]
|
||||||
|
x-go-type: string
|
||||||
|
description: pass = MX present, warn = only A/AAAA records (implicit MX), fail = no records
|
||||||
|
has_mx:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the domain has at least one MX record
|
||||||
|
has_address:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the domain has an A or AAAA record (implicit MX fallback)
|
||||||
|
org_domain:
|
||||||
|
type: string
|
||||||
|
description: Organizational domain used as fallback when the domain itself had no records
|
||||||
|
example: "example.com"
|
||||||
|
|
||||||
MXRecord:
|
MXRecord:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|
@ -1152,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
|
||||||
|
|
@ -1203,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:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@
|
||||||
|
|
||||||
"PTR" : {},
|
"PTR" : {},
|
||||||
|
|
||||||
|
"TLS" : {},
|
||||||
|
|
||||||
"SenderID" : {
|
"SenderID" : {
|
||||||
"hide_none" : 1
|
"hide_none" : 1
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,8 @@ smtpd_recipient_restrictions =
|
||||||
permit_mynetworks,
|
permit_mynetworks,
|
||||||
reject_unauth_destination
|
reject_unauth_destination
|
||||||
|
|
||||||
|
# TLS - record the negotiated cipher/protocol in the Received: header
|
||||||
|
smtpd_tls_received_header = yes
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
debug_peer_level = 2
|
debug_peer_level = 2
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
# SMTP service
|
# SMTP service
|
||||||
smtp inet n - n - - smtpd
|
smtp inet n - n - - smtpd
|
||||||
|
|
||||||
|
# TLS session cache and PRNG manager (required for STARTTLS)
|
||||||
|
tlsmgr unix - - n 1000? 1 tlsmgr
|
||||||
|
|
||||||
# Pickup service
|
# Pickup service
|
||||||
pickup unix n - n 60 1 pickup
|
pickup unix n - n 60 1 pickup
|
||||||
|
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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") == "" {
|
||||||
|
|
|
||||||
40
internal/config/blacklist.go
Normal file
40
internal/config/blacklist.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
results.XPtr = a.parseXPtrResult(part)
|
results.XPtr = a.parseXPtrResult(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse x-tls
|
||||||
|
if strings.HasPrefix(part, "x-tls=") {
|
||||||
|
if results.XTls == nil {
|
||||||
|
results.XTls = a.parseXTLSResult(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +190,9 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut
|
||||||
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
||||||
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
||||||
|
|
||||||
|
// Penalty-only: X-TLS / transport encryption (-10 points when not encrypted)
|
||||||
|
score += 10 * a.calculateXTLSScore(results) / 100
|
||||||
|
|
||||||
// Ensure score doesn't exceed 100
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
154
pkg/analyzer/authentication_x_tls.go
Normal file
154
pkg/analyzer/authentication_x_tls.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// 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 (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseXTLSResult parses the x-tls result from Authentication-Results.
|
||||||
|
// Example: x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256
|
||||||
|
func (a *AuthenticationAnalyzer) parseXTLSResult(part string) *model.AuthResult {
|
||||||
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
|
// Extract result (pass, fail, none, ...)
|
||||||
|
re := regexp.MustCompile(`x-tls=(\w+)`)
|
||||||
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
|
result.Result = model.AuthResultResult(strings.ToLower(matches[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Details = utils.PtrTo(formatTLSDetails(
|
||||||
|
submatch(part, `smtp\.version=([^\s;()]+)`),
|
||||||
|
submatch(part, `smtp\.cipher=([^\s;()]+)`),
|
||||||
|
submatch(part, `smtp\.bits=(\d+)`),
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateXTLSScore returns a penalty for a negative transport-TLS result.
|
||||||
|
// pass (or absent) does not alter the score; any other result is penalized.
|
||||||
|
func (a *AuthenticationAnalyzer) calculateXTLSScore(results *model.AuthenticationResults) (score int) {
|
||||||
|
if results.XTls != nil {
|
||||||
|
switch results.XTls.Result {
|
||||||
|
case model.AuthResultResultPass:
|
||||||
|
// pass: don't alter the score
|
||||||
|
default:
|
||||||
|
return -100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileXTLS fills in the x-tls result from the inbound connection's parsed TLS
|
||||||
|
// information when no x-tls Authentication-Results header was present. The inbound
|
||||||
|
// connection is the most recent hop (index 0) of the received chain.
|
||||||
|
func (a *AuthenticationAnalyzer) ReconcileXTLS(results *model.AuthenticationResults, chain *[]model.ReceivedHop) {
|
||||||
|
if results == nil || results.XTls != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if chain == nil || len(*chain) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inbound := (*chain)[0]
|
||||||
|
switch {
|
||||||
|
case inbound.Tls != nil:
|
||||||
|
// Full TLS parenthetical present (smtpd_tls_received_header = yes).
|
||||||
|
var version, cipher, bits string
|
||||||
|
if inbound.Tls.Version != nil {
|
||||||
|
version = *inbound.Tls.Version
|
||||||
|
}
|
||||||
|
if inbound.Tls.Cipher != nil {
|
||||||
|
cipher = *inbound.Tls.Cipher
|
||||||
|
}
|
||||||
|
if inbound.Tls.Bits != nil {
|
||||||
|
bits = fmt.Sprintf("%d", *inbound.Tls.Bits)
|
||||||
|
}
|
||||||
|
results.XTls = &model.AuthResult{
|
||||||
|
Result: model.AuthResultResultPass,
|
||||||
|
Details: utils.PtrTo(formatTLSDetails(version, cipher, bits)),
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocolIndicatesTLS(inbound.With):
|
||||||
|
// No TLS parenthetical (smtpd_tls_received_header may be disabled), but the
|
||||||
|
// transport keyword (ESMTPS, ESMTPSA, ...) tells us the session was encrypted.
|
||||||
|
// We just don't have the cipher details.
|
||||||
|
results.XTls = &model.AuthResult{
|
||||||
|
Result: model.AuthResultResultPass,
|
||||||
|
Details: utils.PtrTo(fmt.Sprintf("Encrypted connection (%s); cipher details unavailable", *inbound.With)),
|
||||||
|
}
|
||||||
|
|
||||||
|
case inbound.With != nil:
|
||||||
|
// A plaintext transport keyword (SMTP, ESMTP, ESMTPA, ...) is positive
|
||||||
|
// evidence the inbound connection was not encrypted.
|
||||||
|
results.XTls = &model.AuthResult{
|
||||||
|
Result: model.AuthResultResultNone,
|
||||||
|
Details: utils.PtrTo(fmt.Sprintf("Inbound connection was not encrypted (%s)", *inbound.With)),
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Neither TLS details nor a transport keyword: we cannot tell whether the
|
||||||
|
// connection was encrypted. Leave x-tls unset rather than wrongly penalize.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protocolIndicatesTLS reports whether an SMTP "with" transport keyword denotes a
|
||||||
|
// TLS-encrypted session. Per RFC 3848 the keyword gains a trailing "S" when STARTTLS
|
||||||
|
// (or implicit TLS) was negotiated: ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA, UTF8SMTPS...
|
||||||
|
// The plaintext variants end in "P" (SMTP, ESMTP, LMTP) or "A" (ESMTPA, LMTPA).
|
||||||
|
func protocolIndicatesTLS(with *string) bool {
|
||||||
|
if with == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p := strings.ToUpper(strings.TrimSpace(*with))
|
||||||
|
return strings.HasSuffix(p, "S") || strings.HasSuffix(p, "SA")
|
||||||
|
}
|
||||||
|
|
||||||
|
// submatch returns the first capture group of pattern in s, or "".
|
||||||
|
func submatch(s, pattern string) string {
|
||||||
|
if matches := regexp.MustCompile(pattern).FindStringSubmatch(s); len(matches) > 1 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTLSDetails builds a human-readable summary of the TLS parameters.
|
||||||
|
func formatTLSDetails(version, cipher, bits string) string {
|
||||||
|
var parts []string
|
||||||
|
if version != "" {
|
||||||
|
parts = append(parts, version)
|
||||||
|
}
|
||||||
|
if cipher != "" {
|
||||||
|
parts = append(parts, "cipher "+cipher)
|
||||||
|
}
|
||||||
|
if bits != "" {
|
||||||
|
parts = append(parts, bits+" bits")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
165
pkg/analyzer/authentication_x_tls_test.go
Normal file
165
pkg/analyzer/authentication_x_tls_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// 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 (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseXTLSResult(t *testing.T) {
|
||||||
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
|
result := analyzer.parseXTLSResult("x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256")
|
||||||
|
|
||||||
|
if result.Result != model.AuthResultResultPass {
|
||||||
|
t.Errorf("Result = %v, want pass", result.Result)
|
||||||
|
}
|
||||||
|
if result.Details == nil {
|
||||||
|
t.Fatal("Details should not be nil")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"TLSv1.3", "TLS_AES_256_GCM_SHA384", "256 bits"} {
|
||||||
|
if !strings.Contains(*result.Details, want) {
|
||||||
|
t.Errorf("Details %q should contain %q", *result.Details, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateXTLSScore(t *testing.T) {
|
||||||
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
xtls *model.AuthResult
|
||||||
|
score int
|
||||||
|
}{
|
||||||
|
{"nil", nil, 0},
|
||||||
|
{"pass", &model.AuthResult{Result: model.AuthResultResultPass}, 0},
|
||||||
|
{"none", &model.AuthResult{Result: model.AuthResultResultNone}, -100},
|
||||||
|
{"fail", &model.AuthResult{Result: model.AuthResultResultFail}, -100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{XTls: tt.xtls}
|
||||||
|
if got := analyzer.calculateXTLSScore(results); got != tt.score {
|
||||||
|
t.Errorf("calculateXTLSScore = %d, want %d", got, tt.score)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileXTLS(t *testing.T) {
|
||||||
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
|
t.Run("keeps existing x-tls header result", func(t *testing.T) {
|
||||||
|
existing := &model.AuthResult{Result: model.AuthResultResultFail}
|
||||||
|
results := &model.AuthenticationResults{XTls: existing}
|
||||||
|
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{Version: utils.PtrTo("TLSv1.3")}}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls != existing {
|
||||||
|
t.Error("existing XTls should be preserved")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("synthesizes pass from encrypted inbound hop", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{
|
||||||
|
Version: utils.PtrTo("TLSv1.3"),
|
||||||
|
Cipher: utils.PtrTo("TLS_AES_256_GCM_SHA384"),
|
||||||
|
Bits: utils.PtrTo(256),
|
||||||
|
}}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
||||||
|
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
if results.XTls.Details == nil || !strings.Contains(*results.XTls.Details, "TLSv1.3") {
|
||||||
|
t.Errorf("details should mention TLS version, got %v", results.XTls.Details)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("synthesizes pass from ESMTPS protocol without TLS parenthetical", func(t *testing.T) {
|
||||||
|
// smtpd_tls_received_header disabled: no TLS details, but ESMTPS proves encryption.
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTPS")}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
||||||
|
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("synthesizes none from plaintext ESMTP protocol", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTP")}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls == nil || results.XTls.Result != model.AuthResultResultNone {
|
||||||
|
t.Fatalf("expected synthesized none, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("leaves nil when neither TLS info nor protocol is known", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls != nil {
|
||||||
|
t.Errorf("expected nil XTls when undetermined, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("leaves nil with empty chain", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
analyzer.ReconcileXTLS(results, &[]model.ReceivedHop{})
|
||||||
|
if results.XTls != nil {
|
||||||
|
t.Errorf("expected nil XTls, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtocolIndicatesTLS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
with string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ESMTPS", true},
|
||||||
|
{"ESMTPSA", true},
|
||||||
|
{"SMTPS", true},
|
||||||
|
{"LMTPS", true},
|
||||||
|
{"LMTPSA", true},
|
||||||
|
{"SMTP", false},
|
||||||
|
{"ESMTP", false},
|
||||||
|
{"ESMTPA", false},
|
||||||
|
{"LMTP", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.with, func(t *testing.T) {
|
||||||
|
if got := protocolIndicatesTLS(utils.PtrTo(tt.with)); got != tt.want {
|
||||||
|
t.Errorf("protocolIndicatesTLS(%q) = %v, want %v", tt.with, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if protocolIndicatesTLS(nil) {
|
||||||
|
t.Error("protocolIndicatesTLS(nil) should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -110,6 +110,15 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
|
||||||
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
|
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the sender domains can actually receive replies/bounces (MX, with
|
||||||
|
// A/AAAA fallback), mirroring the ReturnOK milter check.
|
||||||
|
results.ReturnOk = &model.ReturnOK{
|
||||||
|
From: d.checkReturnOKDomain(fromDomain, orgDomainOrEmpty(headersResults.DomainAlignment.FromOrgDomain)),
|
||||||
|
}
|
||||||
|
if results.RpDomain != nil && *results.RpDomain != "" {
|
||||||
|
results.ReturnOk.ReturnPath = d.checkReturnOKDomain(*results.RpDomain, orgDomainOrEmpty(headersResults.DomainAlignment.ReturnPathOrgDomain))
|
||||||
|
}
|
||||||
|
|
||||||
// Check SPF records (for Return-Path domain - this is the envelope sender)
|
// Check SPF records (for Return-Path domain - this is the envelope sender)
|
||||||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||||
|
|
@ -148,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
|
||||||
// Check SPF records
|
// Check SPF records
|
||||||
results.SpfRecords = d.checkSPFRecords(domain)
|
results.SpfRecords = d.checkSPFRecords(domain)
|
||||||
|
|
||||||
|
// Verify the domain can receive replies/bounces (MX, with A/AAAA fallback)
|
||||||
|
results.ReturnOk = &model.ReturnOK{
|
||||||
|
From: d.checkReturnOKDomain(domain, ""),
|
||||||
|
}
|
||||||
|
|
||||||
// Check DMARC record
|
// Check DMARC record
|
||||||
results.DmarcRecord = d.checkDMARCRecord(domain)
|
results.DmarcRecord = d.checkDMARCRecord(domain)
|
||||||
|
|
||||||
|
|
@ -179,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
|
||||||
// DMARC Record: 40 points
|
// DMARC Record: 40 points
|
||||||
score += 40 * d.calculateDMARCScore(results) / 100
|
score += 40 * d.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
|
// Penalty when a sender domain cannot receive replies/bounces at all
|
||||||
|
score += calculateReturnOKPenalty(results)
|
||||||
|
|
||||||
// BIMI Record: only bonus
|
// BIMI Record: only bonus
|
||||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||||
if score >= 100 {
|
if score >= 100 {
|
||||||
|
|
@ -224,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri
|
||||||
// DMARC Record: 20 points
|
// DMARC Record: 20 points
|
||||||
score += 20 * d.calculateDMARCScore(results) / 100
|
score += 20 * d.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
|
// Penalty when a sender domain cannot receive replies/bounces at all
|
||||||
|
score += calculateReturnOKPenalty(results)
|
||||||
|
|
||||||
// BIMI Record
|
// BIMI Record
|
||||||
// BIMI is optional but indicates advanced email branding
|
// BIMI is optional but indicates advanced email branding
|
||||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||||
|
|
|
||||||
113
pkg/analyzer/dns_returnok.go
Normal file
113
pkg/analyzer/dns_returnok.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025-2026 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"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReturnOKDomain.Status values, matching the schema enum. Kept as a plain string
|
||||||
|
// in the generated model (x-go-type) to avoid colliding with other "pass"/"fail"
|
||||||
|
// enums in the global enum namespace.
|
||||||
|
const (
|
||||||
|
returnOKStatusPass = "pass"
|
||||||
|
returnOKStatusWarn = "warn"
|
||||||
|
returnOKStatusFail = "fail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainCanReceive reports whether a domain can accept mail, looking up records
|
||||||
|
// in the same order as Fastmail's ReturnOK milter: MX first, then A/AAAA.
|
||||||
|
func (d *DNSAnalyzer) domainCanReceive(domain string) (hasMX, hasAddress bool) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if mxRecords, err := d.resolver.LookupMX(ctx, domain); err == nil && len(mxRecords) > 0 {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if addrs, err := d.resolver.LookupHost(ctx, domain); err == nil && len(addrs) > 0 {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkReturnOKDomain verifies that a domain can receive replies/bounces.
|
||||||
|
// It checks the domain itself, then falls back to its organizational domain
|
||||||
|
// (when different) the same way the ReturnOK milter retries the org domain.
|
||||||
|
func (d *DNSAnalyzer) checkReturnOKDomain(domain, orgDomain string) *model.ReturnOKDomain {
|
||||||
|
if domain == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &model.ReturnOKDomain{Domain: domain}
|
||||||
|
|
||||||
|
hasMX, hasAddress := d.domainCanReceive(domain)
|
||||||
|
|
||||||
|
// Fall back to the organizational domain when the domain itself has nothing.
|
||||||
|
if !hasMX && !hasAddress && orgDomain != "" && orgDomain != domain {
|
||||||
|
if orgMX, orgAddr := d.domainCanReceive(orgDomain); orgMX || orgAddr {
|
||||||
|
hasMX, hasAddress = orgMX, orgAddr
|
||||||
|
result.OrgDomain = utils.PtrTo(orgDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.HasMx = utils.PtrTo(hasMX)
|
||||||
|
result.HasAddress = utils.PtrTo(hasAddress)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hasMX:
|
||||||
|
result.Status = returnOKStatusPass
|
||||||
|
case hasAddress:
|
||||||
|
result.Status = returnOKStatusWarn
|
||||||
|
default:
|
||||||
|
result.Status = returnOKStatusFail
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateReturnOKPenalty returns a non-positive value: each sender domain that
|
||||||
|
// can receive neither replies nor bounces (status=fail) costs points, since
|
||||||
|
// those messages would be silently lost.
|
||||||
|
func calculateReturnOKPenalty(results *model.DNSResults) (penalty int) {
|
||||||
|
if results.ReturnOk == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, dom := range []*model.ReturnOKDomain{results.ReturnOk.From, results.ReturnOk.ReturnPath} {
|
||||||
|
if dom != nil && dom.Status == returnOKStatusFail {
|
||||||
|
penalty -= 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// orgDomainOrEmpty dereferences an optional organizational domain pointer.
|
||||||
|
func orgDomainOrEmpty(orgDomain *string) string {
|
||||||
|
if orgDomain == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *orgDomain
|
||||||
|
}
|
||||||
170
pkg/analyzer/dns_returnok_test.go
Normal file
170
pkg/analyzer/dns_returnok_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025-2026 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"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// returnOKMockResolver lets tests control MX and host (A/AAAA) lookups per domain.
|
||||||
|
type returnOKMockResolver struct {
|
||||||
|
mx map[string][]*net.MX
|
||||||
|
hosts map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *returnOKMockResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
|
||||||
|
if recs, ok := m.mx[name]; ok {
|
||||||
|
return recs, nil
|
||||||
|
}
|
||||||
|
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *returnOKMockResolver) LookupHost(_ context.Context, host string) ([]string, error) {
|
||||||
|
if recs, ok := m.hosts[host]; ok {
|
||||||
|
return recs, nil
|
||||||
|
}
|
||||||
|
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *returnOKMockResolver) LookupTXT(_ context.Context, _ string) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *returnOKMockResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckReturnOKDomain(t *testing.T) {
|
||||||
|
mx := []*net.MX{{Host: "mail.example.com.", Pref: 10}}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
orgDomain string
|
||||||
|
resolver *returnOKMockResolver
|
||||||
|
wantStatus string
|
||||||
|
wantHasMX bool
|
||||||
|
wantHasAddr bool
|
||||||
|
wantOrgDomain string // "" means OrgDomain should be nil
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "domain with MX passes",
|
||||||
|
domain: "example.com",
|
||||||
|
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
|
||||||
|
wantStatus: returnOKStatusPass,
|
||||||
|
wantHasMX: true,
|
||||||
|
wantHasAddr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no MX but A/AAAA warns",
|
||||||
|
domain: "example.com",
|
||||||
|
resolver: &returnOKMockResolver{hosts: map[string][]string{"example.com": {"192.0.2.1"}}},
|
||||||
|
wantStatus: returnOKStatusWarn,
|
||||||
|
wantHasMX: false,
|
||||||
|
wantHasAddr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to org domain MX",
|
||||||
|
domain: "sub.example.com",
|
||||||
|
orgDomain: "example.com",
|
||||||
|
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
|
||||||
|
wantStatus: returnOKStatusPass,
|
||||||
|
wantHasMX: true,
|
||||||
|
wantHasAddr: false,
|
||||||
|
wantOrgDomain: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing anywhere fails",
|
||||||
|
domain: "example.com",
|
||||||
|
orgDomain: "example.com",
|
||||||
|
resolver: &returnOKMockResolver{},
|
||||||
|
wantStatus: returnOKStatusFail,
|
||||||
|
wantHasMX: false,
|
||||||
|
wantHasAddr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d := NewDNSAnalyzerWithResolver(5*time.Second, tt.resolver)
|
||||||
|
got := d.checkReturnOKDomain(tt.domain, tt.orgDomain)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("checkReturnOKDomain returned nil")
|
||||||
|
}
|
||||||
|
if got.Status != tt.wantStatus {
|
||||||
|
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if got.HasMx == nil || *got.HasMx != tt.wantHasMX {
|
||||||
|
t.Errorf("HasMx = %v, want %v", got.HasMx, tt.wantHasMX)
|
||||||
|
}
|
||||||
|
if got.HasAddress == nil || *got.HasAddress != tt.wantHasAddr {
|
||||||
|
t.Errorf("HasAddress = %v, want %v", got.HasAddress, tt.wantHasAddr)
|
||||||
|
}
|
||||||
|
if tt.wantOrgDomain == "" {
|
||||||
|
if got.OrgDomain != nil {
|
||||||
|
t.Errorf("OrgDomain = %v, want nil", *got.OrgDomain)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if got.OrgDomain == nil || *got.OrgDomain != tt.wantOrgDomain {
|
||||||
|
t.Errorf("OrgDomain = %v, want %q", got.OrgDomain, tt.wantOrgDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckReturnOKDomainEmpty(t *testing.T) {
|
||||||
|
d := NewDNSAnalyzerWithResolver(5*time.Second, &returnOKMockResolver{})
|
||||||
|
if got := d.checkReturnOKDomain("", ""); got != nil {
|
||||||
|
t.Errorf("checkReturnOKDomain(\"\") = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateReturnOKPenalty(t *testing.T) {
|
||||||
|
fail := &model.ReturnOKDomain{Domain: "a.example", Status: returnOKStatusFail}
|
||||||
|
pass := &model.ReturnOKDomain{Domain: "b.example", Status: returnOKStatusPass}
|
||||||
|
warn := &model.ReturnOKDomain{Domain: "c.example", Status: returnOKStatusWarn}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
results *model.DNSResults
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"nil return_ok", &model.DNSResults{}, 0},
|
||||||
|
{"both pass", &model.DNSResults{ReturnOk: &model.ReturnOK{From: pass, ReturnPath: pass}}, 0},
|
||||||
|
{"warn is not penalised", &model.DNSResults{ReturnOk: &model.ReturnOK{From: warn}}, 0},
|
||||||
|
{"one fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: pass}}, -10},
|
||||||
|
{"both fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: fail}}, -20},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := calculateReturnOKPenalty(tt.results); got != tt.want {
|
||||||
|
t.Errorf("calculateReturnOKPenalty() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -693,5 +694,50 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.Receiv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract TLS details from the Received header parentheticals
|
||||||
|
// (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)")
|
||||||
|
hop.Tls = parseReceivedTLS(normalized)
|
||||||
|
|
||||||
return hop
|
return hop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseReceivedTLS extracts TLS connection details from a normalized Received header value.
|
||||||
|
// Returns nil when the hop was not encrypted (no TLS version/cipher found).
|
||||||
|
func parseReceivedTLS(normalized string) *model.TLSInfo {
|
||||||
|
tls := &model.TLSInfo{}
|
||||||
|
found := false
|
||||||
|
|
||||||
|
// TLS protocol version, e.g. "using TLSv1.3"
|
||||||
|
if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||||||
|
tls.Version = &matches[1]
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384"
|
||||||
|
if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||||||
|
tls.Cipher = &matches[1]
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher strength, e.g. "(256/256 bits)"
|
||||||
|
if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||||||
|
if bits, err := strconv.Atoi(matches[1]); err == nil {
|
||||||
|
tls.Bits = &bits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificate verification status. Postfix emits "(verified OK)" when the peer
|
||||||
|
// certificate was trusted, "(not verified)" otherwise. "No client certificate
|
||||||
|
// requested" leaves the field unset (trust is simply not applicable).
|
||||||
|
if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) {
|
||||||
|
tls.Verified = utils.PtrTo(true)
|
||||||
|
} else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) {
|
||||||
|
tls.Verified = utils.PtrTo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tls
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -677,6 +677,77 @@ func TestParseReceivedHeader(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseReceivedTLS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
receivedValue string
|
||||||
|
expectNil bool
|
||||||
|
expectVersion *string
|
||||||
|
expectCipher *string
|
||||||
|
expectBits *int
|
||||||
|
expectVerified *bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TLS 1.3 no client certificate",
|
||||||
|
receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " +
|
||||||
|
"(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " +
|
||||||
|
"key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " +
|
||||||
|
"(No client certificate requested) " +
|
||||||
|
"by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
||||||
|
expectVersion: strPtr("TLSv1.3"),
|
||||||
|
expectCipher: strPtr("TLS_AES_256_GCM_SHA384"),
|
||||||
|
expectBits: intPtr(256),
|
||||||
|
expectVerified: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS with verified client certificate",
|
||||||
|
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " +
|
||||||
|
"(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " +
|
||||||
|
"(Client CN \"example\", Issuer \"CA\" (verified OK)) " +
|
||||||
|
"by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
|
expectVersion: strPtr("TLSv1.2"),
|
||||||
|
expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"),
|
||||||
|
expectBits: intPtr(128),
|
||||||
|
expectVerified: boolPtr(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plaintext (no TLS)",
|
||||||
|
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
|
expectNil: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
normalized := strings.Join(strings.Fields(tt.receivedValue), " ")
|
||||||
|
tls := parseReceivedTLS(normalized)
|
||||||
|
|
||||||
|
if tt.expectNil {
|
||||||
|
if tls != nil {
|
||||||
|
t.Fatalf("expected nil TLS info, got %+v", tls)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tls == nil {
|
||||||
|
t.Fatal("parseReceivedTLS returned nil")
|
||||||
|
}
|
||||||
|
if !equalStrPtr(tls.Version, tt.expectVersion) {
|
||||||
|
t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion))
|
||||||
|
}
|
||||||
|
if !equalStrPtr(tls.Cipher, tt.expectCipher) {
|
||||||
|
t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher))
|
||||||
|
}
|
||||||
|
if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) {
|
||||||
|
t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits)
|
||||||
|
}
|
||||||
|
if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) {
|
||||||
|
t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
||||||
analyzer := NewHeaderAnalyzer()
|
analyzer := NewHeaderAnalyzer()
|
||||||
|
|
||||||
|
|
@ -908,6 +979,10 @@ func strPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
func ptrToStr(p *string) string {
|
func ptrToStr(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return "<nil>"
|
return "<nil>"
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
|
// Fall back to the received chain's inbound TLS when no x-tls header was present.
|
||||||
|
if results.Authentication != nil && results.Headers != nil {
|
||||||
|
r.authAnalyzer.ReconcileXTLS(results.Authentication, results.Headers.ReceivedChain)
|
||||||
|
}
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,40 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- X-TLS (Transport encryption) -->
|
||||||
|
{#if authentication.x_tls}
|
||||||
|
<div class="list-group-item" id="authentication-x-tls">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i
|
||||||
|
class="bi {getAuthResultIcon(
|
||||||
|
authentication.x_tls.result,
|
||||||
|
true,
|
||||||
|
)} {getAuthResultClass(authentication.x_tls.result, true)} me-2 fs-5"
|
||||||
|
></i>
|
||||||
|
<div>
|
||||||
|
<strong>Transport TLS</strong>
|
||||||
|
<i
|
||||||
|
class="bi bi-info-circle text-muted ms-1"
|
||||||
|
title="Whether the inbound connection that delivered this message used TLS encryption (x-tls). Falls back to the inbound Received hop when no x-tls header is present."
|
||||||
|
></i>
|
||||||
|
<span
|
||||||
|
class="text-uppercase ms-2 {getAuthResultClass(
|
||||||
|
authentication.x_tls.result,
|
||||||
|
true,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{authentication.x_tls.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.x_tls.details}
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
{authentication.x_tls.details}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- SPF (Required) -->
|
<!-- SPF (Required) -->
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex align-items-start" id="authentication-spf">
|
<div class="d-flex align-items-start" id="authentication-spf">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||||
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
||||||
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
||||||
|
import ReturnOkDisplay from "./ReturnOkDisplay.svelte";
|
||||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -100,6 +101,9 @@
|
||||||
heloPtrMatch={dnsResults.helo_ptr_match}
|
heloPtrMatch={dnsResults.helo_ptr_match}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Return Address Reachability (ReturnOK) -->
|
||||||
|
<ReturnOkDisplay returnOk={dnsResults.return_ok} />
|
||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
|
|
||||||
<!-- Return-Path Domain Section -->
|
<!-- Return-Path Domain Section -->
|
||||||
|
|
@ -150,8 +154,7 @@
|
||||||
</h4>
|
</h4>
|
||||||
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||||
<span class="badge bg-danger ms-2">
|
<span class="badge bg-danger ms-2">
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
|
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain
|
||||||
domain
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
260
web/src/lib/components/DomainBlacklistCard.svelte
Normal file
260
web/src/lib/components/DomainBlacklistCard.svelte
Normal 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>
|
||||||
|
|
@ -7,6 +7,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let { receivedChain }: Props = $props();
|
let { receivedChain }: Props = $props();
|
||||||
|
|
||||||
|
// Mirror of the backend protocolIndicatesTLS (RFC 3848): the transport keyword
|
||||||
|
// gains a trailing "S" when TLS was used (ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA...).
|
||||||
|
function protocolIndicatesTLS(withProto: string | undefined | null): boolean {
|
||||||
|
if (!withProto) return false;
|
||||||
|
const p = withProto.trim().toUpperCase();
|
||||||
|
return p.endsWith("S") || p.endsWith("SA");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 3848: a trailing "A" means the sender authenticated (SMTP AUTH):
|
||||||
|
// ESMTPA, ESMTPSA, LMTPA, LMTPSA...
|
||||||
|
function protocolIndicatesAuth(withProto: string | undefined | null): boolean {
|
||||||
|
if (!withProto) return false;
|
||||||
|
return withProto.trim().toUpperCase().endsWith("A");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
|
|
@ -60,6 +75,63 @@
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="mb-0 small d-flex flex-wrap align-items-center gap-3">
|
||||||
|
{#if hop.tls}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-lock-fill me-1"></i>TLS
|
||||||
|
</span>
|
||||||
|
{#if hop.tls.version}
|
||||||
|
<span>
|
||||||
|
<span class="text-muted">Version:</span>
|
||||||
|
<code>{hop.tls.version}</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hop.tls.cipher}
|
||||||
|
<span>
|
||||||
|
<span class="text-muted">Cipher:</span>
|
||||||
|
<code>{hop.tls.cipher}</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hop.tls.bits}
|
||||||
|
<span>
|
||||||
|
<span class="text-muted">Strength:</span>
|
||||||
|
<code>{hop.tls.bits} bits</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hop.tls.verified !== undefined}
|
||||||
|
<span
|
||||||
|
class:text-success={hop.tls.verified}
|
||||||
|
class:text-warning={!hop.tls.verified}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="bi {hop.tls.verified
|
||||||
|
? 'bi-patch-check-fill'
|
||||||
|
: 'bi-patch-exclamation-fill'} me-1"
|
||||||
|
></i>
|
||||||
|
{hop.tls.verified
|
||||||
|
? "Certificate trusted"
|
||||||
|
: "Certificate not trusted"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else if protocolIndicatesTLS(hop.with)}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-lock-fill me-1"></i>TLS
|
||||||
|
</span>
|
||||||
|
{:else if hop.with}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-unlock me-1"></i>No TLS
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge bg-light text-muted border">
|
||||||
|
<i class="bi bi-question-circle me-1"></i>TLS unknown
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if protocolIndicatesAuth(hop.with)}
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="bi bi-person-check-fill me-1"></i>Authenticated
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
106
web/src/lib/components/ReturnOkDisplay.svelte
Normal file
106
web/src/lib/components/ReturnOkDisplay.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SchemasReturnOk, SchemasReturnOkDomain } from "$lib/api/types.gen";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
returnOk?: SchemasReturnOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { returnOk }: Props = $props();
|
||||||
|
|
||||||
|
type Row = { label: string; entry: SchemasReturnOkDomain };
|
||||||
|
|
||||||
|
const rows = $derived<Row[]>(
|
||||||
|
[
|
||||||
|
returnOk?.from ? { label: "From", entry: returnOk.from } : undefined,
|
||||||
|
returnOk?.return_path
|
||||||
|
? { label: "Return-Path", entry: returnOk.return_path }
|
||||||
|
: undefined,
|
||||||
|
].filter((r): r is Row => r !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFail = $derived(rows.some((r) => r.entry.status === "fail"));
|
||||||
|
const hasWarn = $derived(rows.some((r) => r.entry.status === "warn"));
|
||||||
|
const allPass = $derived(rows.length > 0 && rows.every((r) => r.entry.status === "pass"));
|
||||||
|
|
||||||
|
// Header icon reflects the worst status across the checked domains.
|
||||||
|
const headerOk = $derived(allPass);
|
||||||
|
|
||||||
|
function badgeClass(status: string): string {
|
||||||
|
if (status === "pass") return "bg-success";
|
||||||
|
if (status === "warn") return "bg-warning text-dark";
|
||||||
|
return "bg-danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeLabel(status: string): string {
|
||||||
|
if (status === "pass") return "MX";
|
||||||
|
if (status === "warn") return "A/AAAA only";
|
||||||
|
return "Unreachable";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if rows.length > 0}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="text-muted mb-0">
|
||||||
|
<i
|
||||||
|
class="bi"
|
||||||
|
class:bi-check-circle-fill={headerOk}
|
||||||
|
class:text-success={headerOk}
|
||||||
|
class:bi-exclamation-triangle-fill={!headerOk && !hasFail}
|
||||||
|
class:text-warning={!headerOk && !hasFail}
|
||||||
|
class:bi-x-circle-fill={hasFail}
|
||||||
|
class:text-danger={hasFail}
|
||||||
|
></i>
|
||||||
|
Return Address Reachability
|
||||||
|
</h5>
|
||||||
|
<span class="badge bg-secondary">RETURN-OK</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text small text-muted mb-0">
|
||||||
|
Replies (to the From address) and bounces (to the Return-Path) can only be delivered
|
||||||
|
if the sender's domains accept mail. A domain should publish MX records; an A/AAAA
|
||||||
|
record works as an implicit fallback but is not recommended. A domain with neither
|
||||||
|
is unreachable and silently drops replies and bounces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{#each rows as { label, entry } (label)}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-muted" style="min-width: 6.5rem">{label} domain:</span>
|
||||||
|
<code>{entry.domain}</code>
|
||||||
|
<span class="badge {badgeClass(entry.status)}">
|
||||||
|
{badgeLabel(entry.status)}
|
||||||
|
</span>
|
||||||
|
{#if entry.org_domain}
|
||||||
|
<small class="text-muted">
|
||||||
|
via organizational domain <code>{entry.org_domain}</code>
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if hasFail || hasWarn}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class="list-group-item">
|
||||||
|
{#if hasFail}
|
||||||
|
<div class="alert alert-danger mb-0">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>
|
||||||
|
<strong>Error:</strong> At least one sender domain has no MX and no A/AAAA record.
|
||||||
|
Replies or bounce messages to that domain will be lost. Publish an MX record pointing
|
||||||
|
to a mail server that accepts mail.
|
||||||
|
</div>
|
||||||
|
{:else if hasWarn}
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
<strong>Warning:</strong> A sender domain has no MX record and relies on its A/AAAA
|
||||||
|
record (implicit MX). Mail is still deliverable, but publishing an explicit MX
|
||||||
|
record is recommended.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue