Compare commits

..

5 commits

Author SHA1 Message Date
dee848d887 Rebalance authentication score: SPF/DKIM/DMARC as core, penalties for optional results
Some checks are pending
continuous-integration/drone/push Build is running
IPRev and X-Aligned-From now only penalize on failure instead of
contributing positively. Core authentication (SPF/DKIM/DMARC) rebalanced
to 30 points each, BIMI stays at 10, totaling 100 base points.

Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-26 10:13:37 +07:00
b158336451 Filter Received-SPF header by receiver hostname
Ensures parseLegacySPF only trusts Received-SPF headers where the
receiver= field matches the configured receiverHostname, preventing
incorrect SPF results from unrelated receivers.
2026-03-26 10:13:37 +07:00
a36824cf27 Fix DKIM headers retrieval
Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-26 10:13:28 +07:00
7d3009d7d0 Add rspamd symbol descriptions from embedded/API lookup
Embed rspamd-symbols.json in the binary to provide human-readable
descriptions for rspamd symbols in reports. Optionally fetch fresh
symbols from a configurable rspamd API URL (--rspamd-api-url flag),
falling back to the embedded list on error. Update the frontend to
display descriptions alongside symbol names and scores.
2026-03-26 09:51:45 +07:00
5c104f3c99 Merge RspamdSymbol into SpamTestDetail in OpenAPI spec
Add params field to SpamTestDetail, update RspamdResult.symbols to
reference SpamTestDetail instead of the now-removed RspamdSymbol schema,
and update Go code accordingly.
2026-03-26 08:58:13 +07:00
16 changed files with 6854 additions and 58 deletions

View file

@ -175,7 +175,8 @@ ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
HAPPYDELIVER_DOMAIN=happydeliver.local \
HAPPYDELIVER_ADDRESS_PREFIX=test- \
HAPPYDELIVER_DNS_TIMEOUT=5s \
HAPPYDELIVER_HTTP_TIMEOUT=10s
HAPPYDELIVER_HTTP_TIMEOUT=10s \
HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
# Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]

View file

@ -926,6 +926,10 @@ components:
format: float
description: Score contribution of this test
example: -1.9
params:
type: string
description: Symbol parameters or options
example: "0.02"
description:
type: string
description: Human-readable description of what this test checks
@ -975,7 +979,7 @@ components:
symbols:
type: object
additionalProperties:
$ref: '#/components/schemas/RspamdSymbol'
$ref: '#/components/schemas/SpamTestDetail'
description: Map of triggered rspamd symbols to their details
example:
BAYES_HAM:
@ -986,25 +990,6 @@ components:
type: string
description: Full rspamd report (raw X-Spamd-Result header)
RspamdSymbol:
type: object
required:
- name
- score
properties:
name:
type: string
description: Symbol name
example: "BAYES_HAM"
score:
type: number
format: float
description: Score contribution of this symbol
example: -1.9
params:
type: string
description: Symbol parameters or options
example: "0.02"
DNSResults:
type: object

View file

@ -39,6 +39,7 @@ func declareFlags(o *Config) {
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
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.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
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.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")

View file

@ -72,7 +72,8 @@ type AnalysisConfig struct {
HTTPTimeout time.Duration
RBLs []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)
}
// DefaultConfig returns a configuration with sensible defaults

View file

@ -47,6 +47,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
cfg.Analysis.RspamdAPIURL,
)
return &EmailAnalyzer{

View file

@ -152,27 +152,32 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
score := 0
// IPRev (15 points)
score += 15 * a.calculateIPRevScore(results) / 100
// Core authentication (90 points total)
// SPF (30 points)
score += 30 * a.calculateSPFScore(results) / 100
// SPF (25 points)
score += 25 * a.calculateSPFScore(results) / 100
// DKIM (30 points)
score += 30 * a.calculateDKIMScore(results) / 100
// DKIM (23 points)
score += 23 * a.calculateDKIMScore(results) / 100
// X-Google-DKIM (optional) - penalty if failed
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// X-Aligned-From
score += 2 * a.calculateXAlignedFromScore(results) / 100
// DMARC (25 points)
score += 25 * a.calculateDMARCScore(results) / 100
// DMARC (30 points)
score += 30 * a.calculateDMARCScore(results) / 100
// BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100
// Penalty-only: IPRev (up to -7 points on failure)
if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
score += 7 * (iprevScore - 100) / 100
}
// Penalty-only: X-Google-DKIM (up to -12 points on failure)
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// Penalty-only: X-Aligned-From (up to -5 points on failure)
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 {
score += 5 * (xAlignedScore - 100) / 100
}
// Ensure score doesn't exceed 100
if score > 100 {
score = 100

View file

@ -63,6 +63,16 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
return nil
}
// Verify receiver matches our hostname
if a.receiverHostname != "" {
receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`)
if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
if matches[1] != a.receiverHostname {
return nil
}
}
}
result := &api.AuthResult{}
// Extract result (first word)

View file

@ -105,7 +105,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header
results.SpfRecords = d.checkSPFRecords(spfDomain)
// Check DKIM records by parsing DKIM-Signature headers directly
for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) {
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
if dkimRecord != nil {
if results.DkimRecords == nil {

View file

@ -49,11 +49,12 @@ func NewReportGenerator(
rbls []string,
dnswls []string,
checkAllIPs bool,
rspamdAPIURL string,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),

View file

@ -32,7 +32,7 @@ import (
)
func TestNewReportGenerator(t *testing.T) {
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
}
func TestAnalyzeEmail(t *testing.T) {
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
email := createTestEmail()
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
}
func TestGenerateReport(t *testing.T) {
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
testID := uuid.New()
email := createTestEmail()
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
}
func TestGenerateReportWithSpamAssassin(t *testing.T) {
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
}
func TestGenerateRawEmail(t *testing.T) {
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
tests := []struct {
name string

View file

@ -0,0 +1,21 @@
# rspamd-symbols.json
This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
## How to update
Fetch the latest symbols from a running rspamd instance:
```sh
curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
```
Or with docker:
```sh
docker run --rm --name rspamd --pull always rspamd/rspamd
docker exec -u 0 rspamd apt install -y curl
docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
```
Then rebuild the project.

File diff suppressed because it is too large Load diff

View file

@ -37,11 +37,13 @@ const (
)
// RspamdAnalyzer analyzes rspamd results from email headers
type RspamdAnalyzer struct{}
type RspamdAnalyzer struct {
symbols map[string]string
}
// NewRspamdAnalyzer creates a new rspamd analyzer
func NewRspamdAnalyzer() *RspamdAnalyzer {
return &RspamdAnalyzer{}
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
return &RspamdAnalyzer{symbols: symbols}
}
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
@ -59,7 +61,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
}
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
Symbols: make(map[string]api.SpamTestDetail),
}
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
@ -83,6 +85,16 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
result.Server = &server
}
// Populate symbol descriptions from the lookup map
if a.symbols != nil {
for name, sym := range result.Symbols {
if desc, ok := a.symbols[name]; ok {
sym.Description = &desc
result.Symbols[name] = sym
}
}
}
// Derive IsSpam from score vs reject threshold.
if result.Threshold > 0 {
result.IsSpam = result.Score >= result.Threshold
@ -129,7 +141,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
if len(matches) > 2 {
name := matches[1]
score, _ := strconv.ParseFloat(matches[2], 64)
sym := api.RspamdSymbol{
sym := api.SpamTestDetail{
Name: name,
Score: float32(score),
}

View file

@ -0,0 +1,105 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 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 (
_ "embed"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
)
//go:embed rspamd-symbols.json
var embeddedRspamdSymbols []byte
// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON.
type rspamdSymbolGroup struct {
Group string `json:"group"`
Rules []rspamdSymbolEntry `json:"rules"`
}
// rspamdSymbolEntry represents a single rspamd symbol entry.
type rspamdSymbolEntry struct {
Symbol string `json:"symbol"`
Description string `json:"description"`
Weight float64 `json:"weight"`
}
// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map.
func parseRspamdSymbolsJSON(data []byte) map[string]string {
var groups []rspamdSymbolGroup
if err := json.Unmarshal(data, &groups); err != nil {
log.Printf("Failed to parse rspamd symbols JSON: %v", err)
return nil
}
symbols := make(map[string]string, len(groups)*10)
for _, g := range groups {
for _, r := range g.Rules {
if r.Description != "" {
symbols[r.Symbol] = r.Description
}
}
}
return symbols
}
// LoadRspamdSymbols loads rspamd symbol descriptions.
// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error.
func LoadRspamdSymbols(apiURL string) map[string]string {
if apiURL != "" {
if symbols := fetchRspamdSymbols(apiURL); symbols != nil {
return symbols
}
log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL)
}
return parseRspamdSymbolsJSON(embeddedRspamdSymbols)
}
// fetchRspamdSymbols fetches symbol descriptions from the rspamd API.
func fetchRspamdSymbols(apiURL string) map[string]string {
url := strings.TrimRight(apiURL, "/") + "/symbols"
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Printf("Error fetching rspamd symbols: %v", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("rspamd API returned status %d", resp.StatusCode)
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Error reading rspamd symbols response: %v", err)
return nil
}
return parseRspamdSymbolsJSON(body)
}

View file

@ -30,7 +30,7 @@ import (
)
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
analyzer := NewRspamdAnalyzer()
analyzer := NewRspamdAnalyzer(nil)
email := &EmailMessage{Header: make(mail.Header)}
result := analyzer.AnalyzeRspamd(email)
@ -126,12 +126,12 @@ func TestParseSpamdResult(t *testing.T) {
},
}
analyzer := NewRspamdAnalyzer()
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
Symbols: make(map[string]api.SpamTestDetail),
}
analyzer.parseSpamdResult(tt.header, result)
@ -241,7 +241,7 @@ func TestAnalyzeRspamd(t *testing.T) {
},
}
analyzer := NewRspamdAnalyzer()
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -340,7 +340,7 @@ func TestCalculateRspamdScore(t *testing.T) {
},
}
analyzer := NewRspamdAnalyzer()
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -380,7 +380,7 @@ func TestAnalyzeRspamdRealEmail(t *testing.T) {
t.Fatalf("Failed to parse email: %v", err)
}
analyzer := NewRspamdAnalyzer()
analyzer := NewRspamdAnalyzer(nil)
result := analyzer.AnalyzeRspamd(email)
if result == nil {

View file

@ -75,7 +75,7 @@
<tr>
<th>Symbol</th>
<th class="text-end">Score</th>
<th>Parameters</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@ -87,7 +87,14 @@
? "table-success"
: ""}
>
<td class="font-monospace">{symbolName}</td>
<td>
<span class="font-monospace">{symbolName}</span>
{#if symbol.params}
<small class="d-block text-muted">
{symbol.params}
</small>
{/if}
</td>
<td class="text-end">
<span
class={symbol.score > 0
@ -99,7 +106,7 @@
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
</span>
</td>
<td class="small text-muted">{symbol.params ?? ""}</td>
<td class="small text-muted">{symbol.description ?? ""}</td>
</tr>
{/each}
</tbody>