Compare commits

..

43 commits

Author SHA1 Message Date
16b7dcb057 Incorporate DNSWL (whitelist) grade into blacklist scoring
Some checks failed
continuous-integration/drone/push Build is failing
CalculateScore now accepts a forWhitelist flag to handle whitelist
scoring logic separately. The final blacklist grade combines both
RBL and DNSWL results using MinGrade for a more accurate reputation
assessment.
2026-03-26 10:36:27 +07:00
dfa38e8a26 Fix RBL score: return A+ when not listed on any blocklist
Move the ListedCount check before scoringListCount calculation so we
return early with a perfect score when the IP/domain is not listed,
regardless of how many informational-only lists exist.
2026-03-26 10:36:25 +07:00
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
3c192f17fd Improve DKIM summary to distinguish missing records from invalid signatures
All checks were successful
continuous-integration/drone/push Build is passing
Use DNS records instead of authentication results to determine DKIM
presence, enabling a three-state display: passed (green), published but
invalid signature (yellow+red), or no DKIM at all (red).
2026-03-25 12:29:05 +07:00
35fc997390 Add warning banner when all authentication results are missing
All checks were successful
continuous-integration/drone/push Build is passing
Explains the two most common causes: the mail server not being
configured to verify email authentication, or a receiver hostname
mismatch with --receiver-hostname.

Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-25 12:12:08 +07:00
2fcee1b885 Return nil from spam analyzers when primary headers are missing
Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-25 12:12:08 +07:00
26025c96a2 Document --receiver-hostname flag and HAPPYDELIVER_RECEIVER_HOSTNAME env var
Explain how happyDeliver filters Authentication-Results headers by
hostname, how to find the correct authserv-id value, and when to
override it (especially when bypassing the embedded Postfix).

Bug: https://github.com/happyDomain/happydeliver/issues/1
Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-25 12:12:08 +07:00
76ee50a100 Make receiver hostname configurable via --receiver-hostname flag
Remove the package-level global hostname from parser.go.

Adds a log warning when the last Received hop doesn't match the
expected receiver hostname.

Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-25 12:12:08 +07:00
71e0832416 Parse DKIM-Signature headers directly in AnalyzeDNS
Remove authResults parameter from AnalyzeDNS, making it independent of
the authentication analysis step. Instead, parse DKIM-Signature headers
directly to extract domain and selector.

Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-25 12:12:08 +07:00
c96a8b92b8 Readd missing go deps 2026-03-25 12:12:08 +07:00
b1c18a3894 chore(deps): lock file maintenance
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-23 02:25:53 +00:00
c8e28c31ee chore(deps): update module github.com/oapi-codegen/runtime to v1.3.0 2026-03-23 02:25:53 +00:00
1d8ee637da chore(deps): update module github.com/oapi-codegen/runtime to v1.3.0
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-23 09:25:48 +07:00
968f42761f Readd missing go dep
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-19 11:56:06 +07:00
2b70115834 chore(deps): update module golang.org/x/net to v0.52.0
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-19 04:07:17 +00:00
d65840000a chore(deps): update module github.com/gin-gonic/gin to v1.12.0
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 03:28:34 +00:00
61503a1c1f chore(deps): lock file maintenance
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-19 03:13:22 +00:00
26025644b0 chore(deps): update dependency @sveltejs/vite-plugin-svelte to v7
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 03:00:49 +00:00
bd02b8f9ba chore(deps): update dependency vite to v8
Some checks are pending
continuous-integration/drone/push Build is pending
2026-03-19 03:00:40 +00:00
a3b539179e chore(deps): update eslint monorepo to v10
Some checks are pending
continuous-integration/drone/push Build is pending
2026-03-19 03:00:26 +00:00
8b6154c183 feat: add whitelist checks to IP blacklist endpoint and rename checks to blacklists
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-09 16:01:54 +07:00
56e6494a75 rbl: parallelize IP checks against blacklists using goroutines 2026-03-09 15:34:38 +07:00
0176c3803d docker: split ENV declarations for readability and remove default RBL list 2026-03-09 15:22:36 +07:00
21e16fd847 rbl: remove SpamRats entries from default RBL list
Those RBLs requires an API key
2026-03-09 14:08:34 +07:00
edfe498b27 Improve responsiveness 2026-03-09 14:08:34 +07:00
27650a3496 feat: add raw report display to rspamd card
Add a collapsible Raw Report section to RspamdCard, storing the raw
X-Spamd-Result header value and displaying it like SpamAssassin's report.
2026-03-09 14:08:34 +07:00
d9b9ea87c6 refactor: extract email path into standalone card component
Move the received chain display out of BlacklistCard into EmailPathCard,
giving it its own card styling and placing it as a dedicated section on
the report page.
2026-03-09 13:09:11 +07:00
bb47bb7c29 fix: handle nested brackets in rspamd symbol params 2026-03-09 12:52:15 +07:00
da93d6d706 Add rspamd tests 2026-03-09 12:47:24 +07:00
2a2bfe46a8 fix: various small fixes and improvements
- Add 'skipped' to authentication result enum in OpenAPI spec
- Fix optional chaining on bimiResult.details check
- Add rbls field to AppConfig interface
- Restrict theme storage to valid 'light'/'dark' values only
- Fix null coalescing for blacklist result data
- Fix survey source to use domain instead of ip
2026-03-09 12:46:30 +07:00
55e9bcd3d0 refactor: handle DNS whitelists
Introduce a single DNSListChecker struct with flags to avoid code
duplication with already existing RBL checker.
2026-03-09 12:46:16 +07:00
28424729a5 rbl: support informational-only RBL entries
Add DefaultInformationalRBLs (UCEPROTECT L2/L3) and track listings
separately via RelevantListedCount so these broader lists are displayed
but excluded from the deliverability score calculation.
2026-03-07 14:24:35 +07:00
3cc39c9c54 rbl: add more RBL providers
Add 8 new RBL providers (SpamRats, PSBL, DroneBL, Mailspike, RBL-DNS
and NSZones).
2026-03-07 14:23:51 +07:00
f9c5c815d1 spamassassin: disable Validity network rules scoring
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 12:23:17 +07:00
4245f93ce4 Add MIME-Version recommended header check
Validate MIME-Version header value equals "1.0" and subtract 5 points
from the score if the header is present but invalid. Absence is not
penalized.
2026-03-07 12:14:53 +07:00
9679b381c7 fix: mark Message-ID as invalid when multiple headers are present
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 12:05:38 +07:00
7b9c45fb68 summary: color SPF error in red
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 11:42:28 +07:00
b619ebf8c3 Display permerror (SPF test) as error: text-danger
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-07 11:38:09 +07:00
a146940a65 Improve FCrDNS UI: hide non-matching IPs when match exists
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Closes: https://github.com/happyDomain/happydeliver/issues/4
2026-02-23 04:25:48 +07:00
55 changed files with 9629 additions and 1179 deletions

View file

@ -170,7 +170,13 @@ RUN chmod +x /entrypoint.sh
EXPOSE 25 8080 EXPOSE 25 8080
# Default configuration # Default configuration
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
HAPPYDELIVER_DOMAIN=happydeliver.local \
HAPPYDELIVER_ADDRESS_PREFIX=test- \
HAPPYDELIVER_DNS_TIMEOUT=5s \
HAPPYDELIVER_HTTP_TIMEOUT=10s \
HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
# Volume for persistent data # Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]

View file

@ -166,7 +166,24 @@ The server will start on `http://localhost:8080` by default.
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
Choose one of the following way to integrate happyDeliver in your existing setup: #### Receiver Hostname
happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
```bash
./happyDeliver server -receiver-hostname mail.example.com
```
Or via environment variable:
```bash
HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
```
**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
#### Postfix LMTP Transport #### Postfix LMTP Transport

View file

@ -350,6 +350,19 @@ components:
listed: false listed: false
- rbl: "bl.spamcop.net" - rbl: "bl.spamcop.net"
listed: false listed: false
whitelists:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: Map of IP addresses to their DNS whitelist check results (informational only)
example:
"192.0.2.1":
- rbl: "list.dnswl.org"
listed: false
- rbl: "swl.spamhaus.org"
listed: false
content_analysis: content_analysis:
$ref: '#/components/schemas/ContentAnalysis' $ref: '#/components/schemas/ContentAnalysis'
header_analysis: header_analysis:
@ -776,7 +789,7 @@ components:
properties: properties:
result: result:
type: string type: string
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass] enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
description: Authentication result description: Authentication result
example: "pass" example: "pass"
domain: domain:
@ -913,6 +926,10 @@ components:
format: float format: float
description: Score contribution of this test description: Score contribution of this test
example: -1.9 example: -1.9
params:
type: string
description: Symbol parameters or options
example: "0.02"
description: description:
type: string type: string
description: Human-readable description of what this test checks description: Human-readable description of what this test checks
@ -962,33 +979,17 @@ components:
symbols: symbols:
type: object type: object
additionalProperties: additionalProperties:
$ref: '#/components/schemas/RspamdSymbol' $ref: '#/components/schemas/SpamTestDetail'
description: Map of triggered rspamd symbols to their details description: Map of triggered rspamd symbols to their details
example: example:
BAYES_HAM: BAYES_HAM:
name: "BAYES_HAM" name: "BAYES_HAM"
score: -1.9 score: -1.9
params: "0.02" params: "0.02"
report:
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: DNSResults:
type: object type: object
@ -1330,7 +1331,7 @@ components:
type: object type: object
required: required:
- ip - ip
- checks - blacklists
- listed_count - listed_count
- score - score
- grade - grade
@ -1339,7 +1340,7 @@ components:
type: string type: string
description: The IP address that was checked description: The IP address that was checked
example: "192.0.2.1" example: "192.0.2.1"
checks: blacklists:
type: array type: array
items: items:
$ref: '#/components/schemas/BlacklistCheck' $ref: '#/components/schemas/BlacklistCheck'
@ -1359,3 +1360,8 @@ components:
enum: [A+, A, B, C, D, E, F] enum: [A+, A, B, C, D, E, F]
description: Letter grade representation of the score description: Letter grade representation of the score
example: "A+" example: "A+"
whitelists:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: List of DNS whitelist check results (informational only)

View file

@ -110,14 +110,38 @@ Default configuration for the Docker environment:
The container accepts these environment variables: The container accepts these environment variables:
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) - `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
Note that the hostname of the container is used to filter the authentication tests results. ### Receiver Hostname
Example: happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
```bash
docker run -d \
-e HAPPYDELIVER_DOMAIN=example.com \
-e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \
...
```
To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`.
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
Example (all-in-one, no override needed):
```bash ```bash
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
``` ```
Example (external MTA integration):
```bash
docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ...
```
## Volumes ## Volumes
**Required volumes:** **Required volumes:**

View file

@ -48,3 +48,14 @@ rbl_timeout 5
# Don't use user-specific rules # Don't use user-specific rules
user_scores_dsn_timeout 3 user_scores_dsn_timeout 3
user_scores_sql_override 0 user_scores_sql_override 0
# Disable Validity network rules
dns_query_restriction deny sa-trusted.bondedsender.org
dns_query_restriction deny sa-accredit.habeas.com
dns_query_restriction deny bl.score.senderscore.com
score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
score RCVD_IN_VALIDITY_CERTIFIED 0
score RCVD_IN_VALIDITY_RPBL 0
score RCVD_IN_VALIDITY_SAFE 0

22
go.mod
View file

@ -1,15 +1,15 @@
module git.happydns.org/happyDeliver module git.happydns.org/happyDeliver
go 1.24.6 go 1.25.0
require ( require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.133.0 github.com/getkin/kin-openapi v0.133.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.1.2 github.com/oapi-codegen/runtime v1.3.0
golang.org/x/net v0.50.0 golang.org/x/net v0.52.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@ -64,14 +64,14 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect
go.uber.org/mock v0.6.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

38
go.sum
View file

@ -42,8 +42,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
@ -132,8 +132,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc= github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ= github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@ -194,6 +194,8 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
@ -201,11 +203,11 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -213,13 +215,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -235,21 +237,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -41,7 +41,7 @@ import (
type EmailAnalyzer interface { type EmailAnalyzer interface {
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string) AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error) CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
} }
// APIHandler implements the ServerInterface for handling API requests // APIHandler implements the ServerInterface for handling API requests
@ -359,7 +359,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
} }
// Perform blacklist check using analyzer // Perform blacklist check using analyzer
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_ip", Error: "invalid_ip",
@ -372,7 +372,8 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
// Build response // Build response
response := BlacklistCheckResponse{ response := BlacklistCheckResponse{
Ip: request.Ip, Ip: request.Ip,
Checks: checks, Blacklists: checks,
Whitelists: &whitelists,
ListedCount: listedCount, ListedCount: listedCount,
Score: score, Score: score,
Grade: BlacklistCheckResponseGrade(grade), Grade: BlacklistCheckResponseGrade(grade),

View file

@ -34,10 +34,12 @@ func declareFlags(o *Config) {
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())")
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") 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.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.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")

View file

@ -34,6 +34,11 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
) )
func getHostname() string {
h, _ := os.Hostname()
return h
}
// Config represents the application configuration // Config represents the application configuration
type Config struct { type Config struct {
DevProxy string DevProxy string
@ -58,6 +63,7 @@ type EmailConfig struct {
Domain string Domain string
TestAddressPrefix string TestAddressPrefix string
LMTPAddr string LMTPAddr string
ReceiverHostname string
} }
// AnalysisConfig contains timeout and behavior settings for email analysis // AnalysisConfig contains timeout and behavior settings for email analysis
@ -65,7 +71,9 @@ type AnalysisConfig struct {
DNSTimeout time.Duration DNSTimeout time.Duration
HTTPTimeout time.Duration HTTPTimeout time.Duration
RBLs []string RBLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one DNSWLs []string
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 // DefaultConfig returns a configuration with sensible defaults
@ -83,11 +91,13 @@ func DefaultConfig() *Config {
Domain: "happydeliver.local", Domain: "happydeliver.local",
TestAddressPrefix: "test-", TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525", LMTPAddr: "127.0.0.1:2525",
ReceiverHostname: getHostname(),
}, },
Analysis: AnalysisConfig{ Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second, DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second, HTTPTimeout: 10 * time.Second,
RBLs: []string{}, RBLs: []string{},
DNSWLs: []string{},
CheckAllIPs: false, // By default, only check the first IP CheckAllIPs: false, // By default, only check the first IP
}, },
} }

View file

@ -98,6 +98,17 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
// Warn if the last Received hop doesn't match the expected receiver hostname
if r.config.Email.ReceiverHostname != "" &&
result.Report.HeaderAnalysis != nil &&
result.Report.HeaderAnalysis.ReceivedChain != nil &&
len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 {
lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0]
if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname {
log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname)
}
}
// Marshal report to JSON // Marshal report to JSON
reportJSON, err := json.Marshal(result.Report) reportJSON, err := json.Marshal(result.Report)
if err != nil { if err != nil {

View file

@ -41,10 +41,13 @@ type EmailAnalyzer struct {
// NewEmailAnalyzer creates a new email analyzer with the given configuration // NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator( generator := NewReportGenerator(
cfg.Email.ReceiverHostname,
cfg.Analysis.DNSTimeout, cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout, cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs, cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs, cfg.Analysis.CheckAllIPs,
cfg.Analysis.RspamdAPIURL,
) )
return &EmailAnalyzer{ return &EmailAnalyzer{
@ -120,22 +123,28 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
return dnsResults, score, grade return dnsResults, score, grade
} }
// CheckBlacklistIP checks a single IP address against DNS blacklists // CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) { func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
// Check the IP against all configured RBLs // Check the IP against all configured RBLs
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
if err != nil { if err != nil {
return nil, 0, 0, "", err return nil, nil, 0, 0, "", err
} }
// Calculate score using the existing function // Calculate score using the existing function
// Create a minimal RBLResults structure for scoring // Create a minimal RBLResults structure for scoring
results := &RBLResults{ results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{ip: checks}, Checks: map[string][]api.BlacklistCheck{ip: checks},
IPsChecked: []string{ip}, IPsChecked: []string{ip},
ListedCount: listedCount, ListedCount: listedCount,
} }
score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results) score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
return checks, listedCount, score, grade, nil // Check the IP against all configured DNSWLs (informational only)
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
if err != nil {
whitelists = nil
}
return checks, whitelists, listedCount, score, grade, nil
} }

View file

@ -28,11 +28,13 @@ import (
) )
// AuthenticationAnalyzer analyzes email authentication results // AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct{} type AuthenticationAnalyzer struct {
receiverHostname string
}
// NewAuthenticationAnalyzer creates a new authentication analyzer // NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{} return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
} }
// AnalyzeAuthentication extracts and analyzes authentication results from email headers // AnalyzeAuthentication extracts and analyzes authentication results from email headers
@ -40,7 +42,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
results := &api.AuthenticationResults{} results := &api.AuthenticationResults{}
// Parse Authentication-Results headers // Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults() authHeaders := email.GetAuthenticationResults(a.receiverHostname)
for _, header := range authHeaders { for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results) a.parseAuthenticationResultsHeader(header, results)
} }
@ -150,27 +152,32 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
score := 0 score := 0
// IPRev (15 points) // Core authentication (90 points total)
score += 15 * a.calculateIPRevScore(results) / 100 // SPF (30 points)
score += 30 * a.calculateSPFScore(results) / 100
// SPF (25 points) // DKIM (30 points)
score += 25 * a.calculateSPFScore(results) / 100 score += 30 * a.calculateDKIMScore(results) / 100
// DKIM (23 points) // DMARC (30 points)
score += 23 * a.calculateDKIMScore(results) / 100 score += 30 * a.calculateDMARCScore(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
// BIMI (10 points) // BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100 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 // Ensure score doesn't exceed 100
if score > 100 { if score > 100 {
score = 100 score = 100

View file

@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -64,7 +64,7 @@ func TestParseBIMIResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -58,7 +58,7 @@ func TestParseDKIMResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -48,7 +48,7 @@ func TestParseDMARCResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -93,7 +93,7 @@ func TestParseIPRevResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -181,7 +181,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -63,6 +63,16 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
return nil 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{} result := &api.AuthResult{}
// Extract result (first word) // Extract result (first word)

View file

@ -60,7 +60,7 @@ func TestParseSPFResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -161,7 +161,7 @@ func TestParseLegacySPF(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -100,7 +100,7 @@ func TestGetAuthenticationScore(t *testing.T) {
}, },
} }
scorer := NewAuthenticationAnalyzer() scorer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -247,7 +247,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
// This test verifies that only the first occurrence of each auth method is parsed // This test verifies that only the first occurrence of each auth method is parsed
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"

View file

@ -66,7 +66,7 @@ func TestParseXAlignedFromResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -126,7 +126,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -60,7 +60,7 @@ func TestParseXGoogleDKIMResult(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer() analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -54,7 +54,7 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
} }
// AnalyzeDNS performs DNS validation for the email's domain // AnalyzeDNS performs DNS validation for the email's domain
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults {
// Extract domain from From address // Extract domain from From address
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
return &api.DNSResults{ return &api.DNSResults{
@ -104,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
// 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)
// Check DKIM records (from authentication results) // Check DKIM records by parsing DKIM-Signature headers directly
// DKIM can be for any domain, but typically the From domain for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
if authResults != nil && authResults.Dkim != nil { dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
for _, dkim := range *authResults.Dkim { if dkimRecord != nil {
if dkim.Domain != nil && dkim.Selector != nil { if results.DkimRecords == nil {
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) results.DkimRecords = new([]api.DKIMRecord)
if dkimRecord != nil {
if results.DkimRecords == nil {
results.DkimRecords = new([]api.DKIMRecord)
}
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
}
} }
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
} }
} }

View file

@ -29,6 +29,38 @@ import (
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
) )
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
type DKIMHeader struct {
Domain string
Selector string
}
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values.
func parseDKIMSignatures(signatures []string) []DKIMHeader {
var results []DKIMHeader
for _, sig := range signatures {
var domain, selector string
for _, part := range strings.Split(sig, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
val := strings.TrimSpace(kv[1])
switch key {
case "d":
domain = val
case "s":
selector = val
}
}
if domain != "" && selector != "" {
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
}
}
return results
}
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
// DKIM records are at: selector._domainkey.domain // DKIM records are at: selector._domainkey.domain

View file

@ -26,6 +26,220 @@ import (
"time" "time"
) )
func TestParseDKIMSignatures(t *testing.T) {
tests := []struct {
name string
signatures []string
expected []DKIMHeader
}{
{
name: "Empty input",
signatures: nil,
expected: nil,
},
{
name: "Empty string",
signatures: []string{""},
expected: nil,
},
{
name: "Simple Gmail-style",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
},
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
},
{
name: "Microsoft 365 style",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
},
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
},
{
name: "Tab-folded multiline (Postfix-style)",
signatures: []string{
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
},
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
},
{
name: "Space-folded multiline (RFC-style)",
signatures: []string{
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
},
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
},
{
name: "d= and s= on separate continuation lines",
signatures: []string{
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
},
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
},
{
name: "No space after semicolons",
signatures: []string{
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
},
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
},
{
name: "Multiple spaces after semicolons",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
},
{
name: "Ed25519 signature (RFC 8463)",
signatures: []string{
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
},
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
},
{
name: "Multiple signatures (ESP double-signing)",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
},
expected: []DKIMHeader{
{Domain: "mydomain.com", Selector: "mail"},
{Domain: "sendib.com", Selector: "mail"},
},
},
{
name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
signatures: []string{
`v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
},
expected: []DKIMHeader{
{Domain: "football.example.com", Selector: "brisbane"},
{Domain: "football.example.com", Selector: "test"},
},
},
{
name: "Amazon SES long selectors",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
},
expected: []DKIMHeader{
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
},
},
{
name: "Subdomain in d=",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
},
{
name: "Deeply nested subdomain",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
},
{
name: "Selector with hyphens (Microsoft 365 custom domain style)",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
},
{
name: "Selector with dots",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
},
{
name: "Single-character selector",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
},
{
name: "Postmark-style timestamp selector, s= before d=",
signatures: []string{
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
},
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
},
{
name: "d= and s= at the very end",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
},
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
},
{
name: "Full tag set",
signatures: []string{
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
},
{
name: "Missing d= tag",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
},
expected: nil,
},
{
name: "Missing s= tag",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
},
expected: nil,
},
{
name: "Missing both d= and s= tags",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
},
expected: nil,
},
{
name: "Mix of valid and invalid signatures",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{
{Domain: "good.com", Selector: "sel1"},
{Domain: "also-good.com", Selector: "sel2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDKIMSignatures(tt.signatures)
if len(result) != len(tt.expected) {
t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
}
for i := range tt.expected {
if result[i].Domain != tt.expected[i].Domain {
t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
}
if result[i].Selector != tt.expected[i].Selector {
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
}
}
})
}
}
func TestValidateDKIM(t *testing.T) { func TestValidateDKIM(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -109,6 +109,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade -= 1 maxGrade -= 1
} }
// Check MIME-Version header (-5 points if present but not "1.0")
if check, exists := headers["mime-version"]; exists && check.Present {
if check.Valid != nil && !*check.Valid {
score -= 5
}
}
// Check Message-ID format (10 points) // Check Message-ID format (10 points)
if check, exists := headers["message-id"]; exists && check.Present { if check, exists := headers["message-id"]; exists && check.Present {
// If Valid is set and true, award points // If Valid is set and true, award points
@ -266,6 +273,10 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
headers[strings.ToLower(headerName)] = *check headers[strings.ToLower(headerName)] = *check
} }
// Check MIME-Version header (recommended but absence is not penalized)
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
// Check optional headers // Check optional headers
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"} optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
for _, headerName := range optionalHeaders { for _, headerName := range optionalHeaders {
@ -320,12 +331,21 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
valid = false valid = false
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)") headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
} }
if len(email.Header["Message-Id"]) > 1 {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
}
case "Date": case "Date":
// Validate date format // Validate date format
if _, err := h.parseEmailDate(value); err != nil { if _, err := h.parseEmailDate(value); err != nil {
valid = false valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err)) headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
} }
case "MIME-Version":
if value != "1.0" {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
}
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path": case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
// Parse address header using net/mail and get normalized address // Parse address header using net/mail and get normalized address
if normalizedAddr, err := h.validateAddressHeader(value); err != nil { if normalizedAddr, err := h.validateAddressHeader(value); err != nil {

View file

@ -28,16 +28,9 @@ import (
"mime/multipart" "mime/multipart"
"net/mail" "net/mail"
"net/textproto" "net/textproto"
"os"
"strings" "strings"
) )
var hostname = ""
func init() {
hostname, _ = os.Hostname()
}
// EmailMessage represents a parsed email message // EmailMessage represents a parsed email message
type EmailMessage struct { type EmailMessage struct {
Header mail.Header Header mail.Header
@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string {
} }
// GetAuthenticationResults extracts Authentication-Results headers // GetAuthenticationResults extracts Authentication-Results headers
// If hostname is provided, only returns headers that begin with that hostname // If receiverHostname is provided, only returns headers that begin with that hostname
func (e *EmailMessage) GetAuthenticationResults() []string { func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
// If no hostname specified, return all results // If no hostname specified, return all results
if hostname == "" { if receiverHostname == "" {
return allResults return allResults
} }
// Filter results that begin with the specified hostname // Filter results that begin with the specified hostname
var filtered []string var filtered []string
prefix := hostname + ";" prefix := receiverHostname + ";"
for _, result := range allResults { for _, result := range allResults {
// Trim whitespace and check if it starts with hostname; // Trim whitespace and check if it starts with hostname;
trimmed := strings.TrimSpace(result) trimmed := strings.TrimSpace(result)

View file

@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8
} }
func TestGetAuthenticationResults(t *testing.T) { func TestGetAuthenticationResults(t *testing.T) {
// Force hostname
hostname = "example.com"
rawEmail := `From: sender@example.com rawEmail := `From: sender@example.com
To: recipient@example.com To: recipient@example.com
Subject: Test Email Subject: Test Email
@ -123,7 +120,7 @@ Body content.
t.Fatalf("Failed to parse email: %v", err) t.Fatalf("Failed to parse email: %v", err)
} }
authResults := email.GetAuthenticationResults() authResults := email.GetAuthenticationResults("example.com")
if len(authResults) != 2 { if len(authResults) != 2 {
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
} }

View file

@ -27,17 +27,21 @@ import (
"net" "net"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
) )
// RBLChecker checks IP addresses against DNS-based blacklists // DNSListChecker checks IP addresses against DNS-based block/allow lists.
type RBLChecker struct { // It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
Timeout time.Duration type DNSListChecker struct {
RBLs []string Timeout time.Duration
CheckAllIPs bool // Check all IPs found in headers, not just the first one Lists []string
resolver *net.Resolver CheckAllIPs bool // Check all IPs found in headers, not just the first one
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
resolver *net.Resolver
informationalSet map[string]bool // Lists whose hits don't count toward the score
} }
// DefaultRBLs is a list of commonly used RBL providers // DefaultRBLs is a list of commonly used RBL providers
@ -48,40 +52,83 @@ var DefaultRBLs = []string{
"b.barracudacentral.org", // Barracuda "b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List) "cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
"psbl.surriel.com", // PSBL
"dnsbl.dronebl.org", // DroneBL
"bl.mailspike.net", // Mailspike BL
"z.mailspike.net", // Mailspike Z
"bl.rbl-dns.com", // RBL-DNS
"bl.nszones.com", // NSZones
}
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
// These are typically broader lists where being listed is less definitive.
var DefaultInformationalRBLs = []string{
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
}
// DefaultDNSWLs is a list of commonly used DNSWL providers
var DefaultDNSWLs = []string{
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
"swl.spamhaus.org", // Spamhaus Safe Whitelist
} }
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker { func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 { if timeout == 0 {
timeout = 5 * time.Second // Default timeout timeout = 5 * time.Second
} }
if len(rbls) == 0 { if len(rbls) == 0 {
rbls = DefaultRBLs rbls = DefaultRBLs
} }
return &RBLChecker{ informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
Timeout: timeout, for _, rbl := range DefaultInformationalRBLs {
RBLs: rbls, informationalSet[rbl] = true
CheckAllIPs: checkAllIPs, }
resolver: &net.Resolver{ return &DNSListChecker{
PreferGo: true, Timeout: timeout,
}, Lists: rbls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: true,
resolver: &net.Resolver{PreferGo: true},
informationalSet: informationalSet,
} }
} }
// RBLResults represents the results of RBL checks // NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
type RBLResults struct { func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP if timeout == 0 {
IPsChecked []string timeout = 5 * time.Second
ListedCount int }
if len(dnswls) == 0 {
dnswls = DefaultDNSWLs
}
return &DNSListChecker{
Timeout: timeout,
Lists: dnswls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: false,
resolver: &net.Resolver{PreferGo: true},
informationalSet: make(map[string]bool),
}
} }
// CheckEmail checks all IPs found in the email headers against RBLs // DNSListResults represents the results of DNS list checks
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { type DNSListResults struct {
results := &RBLResults{ Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
IPsChecked []string
ListedCount int // Total listings including informational entries
RelevantListedCount int // Listings on scoring (non-informational) lists only
}
// CheckEmail checks all IPs found in the email headers against the configured lists
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
results := &DNSListResults{
Checks: make(map[string][]api.BlacklistCheck), Checks: make(map[string][]api.BlacklistCheck),
} }
// Extract IPs from Received headers
ips := r.extractIPs(email) ips := r.extractIPs(email)
if len(ips) == 0 { if len(ips) == 0 {
return results return results
@ -89,17 +136,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results.IPsChecked = ips results.IPsChecked = ips
// Check each IP against all RBLs
for _, ip := range ips { for _, ip := range ips {
for _, rbl := range r.RBLs { for _, list := range r.Lists {
check := r.checkIP(ip, rbl) check := r.checkIP(ip, list)
results.Checks[ip] = append(results.Checks[ip], check) results.Checks[ip] = append(results.Checks[ip], check)
if check.Listed { if check.Listed {
results.ListedCount++ results.ListedCount++
if !r.informationalSet[list] {
results.RelevantListedCount++
}
} }
} }
// Only check the first IP unless CheckAllIPs is enabled
if !r.CheckAllIPs { if !r.CheckAllIPs {
break break
} }
@ -108,20 +156,26 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
return results return results
} }
// CheckIP checks a single IP address against all configured RBLs // CheckIP checks a single IP address against all configured lists in parallel
func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
// Validate that it's a valid IP address
if !r.isPublicIP(ip) { if !r.isPublicIP(ip) {
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
} }
var checks []api.BlacklistCheck checks := make([]api.BlacklistCheck, len(r.Lists))
listedCount := 0 var wg sync.WaitGroup
// Check the IP against all RBLs for i, list := range r.Lists {
for _, rbl := range r.RBLs { wg.Add(1)
check := r.checkIP(ip, rbl) go func(i int, list string) {
checks = append(checks, check) defer wg.Done()
checks[i] = r.checkIP(ip, list)
}(i, list)
}
wg.Wait()
listedCount := 0
for _, check := range checks {
if check.Listed { if check.Listed {
listedCount++ listedCount++
} }
@ -131,27 +185,19 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
} }
// extractIPs extracts IP addresses from Received headers // extractIPs extracts IP addresses from Received headers
func (r *RBLChecker) extractIPs(email *EmailMessage) []string { func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
var ips []string var ips []string
seenIPs := make(map[string]bool) seenIPs := make(map[string]bool)
// Get all Received headers
receivedHeaders := email.Header["Received"] receivedHeaders := email.Header["Received"]
// Regex patterns for IP addresses
// Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
// Look for IPs in Received headers
for _, received := range receivedHeaders { for _, received := range receivedHeaders {
// Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1) matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches { for _, match := range matches {
// Skip private/reserved IPs
if !r.isPublicIP(match) { if !r.isPublicIP(match) {
continue continue
} }
// Avoid duplicates
if !seenIPs[match] { if !seenIPs[match] {
ips = append(ips, match) ips = append(ips, match)
seenIPs[match] = true seenIPs[match] = true
@ -159,13 +205,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
} }
} }
// If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 { if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP") originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" { if originatingIP != "" {
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
// Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP) cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP) matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) { if matches != "" && r.isPublicIP(matches) {
@ -178,19 +221,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
} }
// isPublicIP checks if an IP address is public (not private, loopback, or reserved) // isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *RBLChecker) isPublicIP(ipStr string) bool { func (r *DNSListChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr) ip := net.ParseIP(ipStr)
if ip == nil { if ip == nil {
return false return false
} }
// Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false return false
} }
// Additional checks for reserved ranges
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() { if ip.IsUnspecified() {
return false return false
} }
@ -198,51 +238,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
return true return true
} }
// checkIP checks a single IP against a single RBL // checkIP checks a single IP against a single DNS list
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
check := api.BlacklistCheck{ check := api.BlacklistCheck{
Rbl: rbl, Rbl: list,
} }
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip) reversedIP := r.reverseIP(ip)
if reversedIP == "" { if reversedIP == "" {
check.Error = api.PtrTo("Failed to reverse IP address") check.Error = api.PtrTo("Failed to reverse IP address")
return check return check
} }
// Construct DNSBL query: reversed-ip.rbl-domain query := fmt.Sprintf("%s.%s", reversedIP, list)
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
// Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel() defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query) addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil { if err != nil {
// Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok { if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound { if dnsErr.IsNotFound {
check.Listed = false check.Listed = false
return check return check
} }
} }
// Other DNS errors
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
return check return check
} }
// If we got a response, check the return code
if len(addrs) > 0 { if len(addrs) > 0 {
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2) check.Response = api.PtrTo(addrs[0])
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255 // In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
// These indicate RBL operational issues, not actual listings if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
check.Listed = false check.Listed = false
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])) check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
} else { } else {
// Normal listing response
check.Listed = true check.Listed = true
} }
} }
@ -250,44 +282,58 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
return check return check
} }
// reverseIP reverses an IPv4 address for DNSBL queries // reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
// Example: 192.0.2.1 -> 1.2.0.192 // Example: 192.0.2.1 -> 1.2.0.192
func (r *RBLChecker) reverseIP(ipStr string) string { func (r *DNSListChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr) ip := net.ParseIP(ipStr)
if ip == nil { if ip == nil {
return "" return ""
} }
// Convert to IPv4
ipv4 := ip.To4() ipv4 := ip.To4()
if ipv4 == nil { if ipv4 == nil {
return "" // IPv6 not supported yet return "" // IPv6 not supported yet
} }
// Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
} }
// CalculateRBLScore calculates the blacklist contribution to deliverability // CalculateScore calculates the list contribution to deliverability.
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) { // Informational lists are not counted in the score.
func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
scoringListCount := len(r.Lists) - len(r.informationalSet)
if forWhitelist {
if results.ListedCount >= scoringListCount {
return 100, "A++"
} else if results.ListedCount > 0 {
return 100, "A+"
} else {
return 95, "A"
}
}
if results == nil || len(results.IPsChecked) == 0 { if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 100, "" return 100, ""
} }
percentage := 100 - results.ListedCount*100/len(r.RBLs) if results.ListedCount <= 0 {
return 100, "A+"
}
percentage := 100 - results.RelevantListedCount*100/scoringListCount
return percentage, ScoreToGrade(percentage) return percentage, ScoreToGrade(percentage)
} }
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL // GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
var listedIPs []string var listedIPs []string
for ip, rblChecks := range results.Checks { for ip, checks := range results.Checks {
for _, check := range rblChecks { for _, check := range checks {
if check.Listed { if check.Listed {
listedIPs = append(listedIPs, ip) listedIPs = append(listedIPs, ip)
break // Only add the IP once break
} }
} }
} }
@ -295,17 +341,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
return listedIPs return listedIPs
} }
// GetRBLsForIP returns all RBLs that list a specific IP // GetListsForIP returns all lists that match a specific IP
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
var rbls []string var lists []string
if rblChecks, exists := results.Checks[ip]; exists { if checks, exists := results.Checks[ip]; exists {
for _, check := range rblChecks { for _, check := range checks {
if check.Listed { if check.Listed {
rbls = append(rbls, check.Rbl) lists = append(lists, check.Rbl)
} }
} }
} }
return rbls return lists
} }

View file

@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) {
if checker.Timeout != tt.expectedTimeout { if checker.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
} }
if len(checker.RBLs) != tt.expectedRBLs { if len(checker.Lists) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
} }
if checker.resolver == nil { if checker.resolver == nil {
t.Error("Resolver should not be nil") t.Error("Resolver should not be nil")
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
func TestGetBlacklistScore(t *testing.T) { func TestGetBlacklistScore(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
results *RBLResults results *DNSListResults
expectedScore int expectedScore int
}{ }{
{ {
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
}, },
{ {
name: "No IPs checked", name: "No IPs checked",
results: &RBLResults{ results: &DNSListResults{
IPsChecked: []string{}, IPsChecked: []string{},
}, },
expectedScore: 100, expectedScore: 100,
}, },
{ {
name: "Not listed on any RBL", name: "Not listed on any RBL",
results: &RBLResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 0, ListedCount: 0,
}, },
@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) {
}, },
{ {
name: "Listed on 1 RBL", name: "Listed on 1 RBL",
results: &RBLResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 1, ListedCount: 1,
}, },
@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) {
}, },
{ {
name: "Listed on 2 RBLs", name: "Listed on 2 RBLs",
results: &RBLResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 2, ListedCount: 2,
}, },
@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) {
}, },
{ {
name: "Listed on 3 RBLs", name: "Listed on 3 RBLs",
results: &RBLResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 3, ListedCount: 3,
}, },
@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) {
}, },
{ {
name: "Listed on 4+ RBLs", name: "Listed on 4+ RBLs",
results: &RBLResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 4, ListedCount: 4,
}, },
@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
score, _ := checker.CalculateRBLScore(tt.results) score, _ := checker.CalculateScore(tt.results)
if score != tt.expectedScore { if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
} }
@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) {
} }
func TestGetUniqueListedIPs(t *testing.T) { func TestGetUniqueListedIPs(t *testing.T) {
results := &RBLResults{ results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{ Checks: map[string][]api.BlacklistCheck{
"198.51.100.1": { "198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true}, {Rbl: "zen.spamhaus.org", Listed: true},
@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) {
} }
func TestGetRBLsForIP(t *testing.T) { func TestGetRBLsForIP(t *testing.T) {
results := &RBLResults{ results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{ Checks: map[string][]api.BlacklistCheck{
"198.51.100.1": { "198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true}, {Rbl: "zen.spamhaus.org", Listed: true},
@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
rbls := checker.GetRBLsForIP(results, tt.ip) rbls := checker.GetListsForIP(results, tt.ip)
if len(rbls) != len(tt.expectedRBLs) { if len(rbls) != len(tt.expectedRBLs) {
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))

View file

@ -35,24 +35,29 @@ type ReportGenerator struct {
spamAnalyzer *SpamAssassinAnalyzer spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer dnsAnalyzer *DNSAnalyzer
rblChecker *RBLChecker rblChecker *DNSListChecker
dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer headerAnalyzer *HeaderAnalyzer
} }
// NewReportGenerator creates a new report generator // NewReportGenerator creates a new report generator
func NewReportGenerator( func NewReportGenerator(
receiverHostname string,
dnsTimeout time.Duration, dnsTimeout time.Duration,
httpTimeout time.Duration, httpTimeout time.Duration,
rbls []string, rbls []string,
dnswls []string,
checkAllIPs bool, checkAllIPs bool,
rspamdAPIURL string,
) *ReportGenerator { ) *ReportGenerator {
return &ReportGenerator{ return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(), authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
spamAnalyzer: NewSpamAssassinAnalyzer(), spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(), rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
contentAnalyzer: NewContentAnalyzer(httpTimeout), contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(), headerAnalyzer: NewHeaderAnalyzer(),
} }
@ -65,7 +70,8 @@ type AnalysisResults struct {
Content *ContentResults Content *ContentResults
DNS *api.DNSResults DNS *api.DNSResults
Headers *api.HeaderAnalysis Headers *api.HeaderAnalysis
RBL *RBLResults RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult Rspamd *api.RspamdResult
} }
@ -79,8 +85,9 @@ 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)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, 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.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email) results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email) results.Content = r.contentAnalyzer.AnalyzeContent(email)
@ -134,8 +141,10 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
blacklistScore := 0 blacklistScore := 0
var blacklistGrade string var blacklistGrade string
var whitelistGrade string
if results.RBL != nil { if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL) blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
} }
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
@ -166,7 +175,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
AuthenticationScore: authScore, AuthenticationScore: authScore,
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade), AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore, BlacklistScore: blacklistScore,
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade), BlacklistGrade: api.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
ContentScore: contentScore, ContentScore: contentScore,
ContentGrade: api.ScoreSummaryContentGrade(contentGrade), ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore, HeaderScore: headerScore,
@ -197,6 +206,11 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
report.Blacklists = &results.RBL.Checks report.Blacklists = &results.RBL.Checks
} }
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
report.Whitelists = &results.DNSWL.Checks
}
// Add SpamAssassin result with individual deliverability score // Add SpamAssassin result with individual deliverability score
if results.SpamAssassin != nil { if results.SpamAssassin != nil {
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade) saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)

View file

@ -32,7 +32,7 @@ import (
) )
func TestNewReportGenerator(t *testing.T) { func TestNewReportGenerator(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
if gen == nil { if gen == nil {
t.Fatal("Expected report generator, got nil") t.Fatal("Expected report generator, got nil")
} }
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
} }
func TestAnalyzeEmail(t *testing.T) { func TestAnalyzeEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
email := createTestEmail() email := createTestEmail()
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
} }
func TestGenerateReport(t *testing.T) { func TestGenerateReport(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
testID := uuid.New() testID := uuid.New()
email := createTestEmail() email := createTestEmail()
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
} }
func TestGenerateReportWithSpamAssassin(t *testing.T) { func TestGenerateReportWithSpamAssassin(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
testID := uuid.New() testID := uuid.New()
email := createTestEmailWithSpamAssassin() email := createTestEmailWithSpamAssassin()
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
} }
func TestGenerateRawEmail(t *testing.T) { func TestGenerateRawEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
tests := []struct { tests := []struct {
name string 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 // RspamdAnalyzer analyzes rspamd results from email headers
type RspamdAnalyzer struct{} type RspamdAnalyzer struct {
symbols map[string]string
}
// NewRspamdAnalyzer creates a new rspamd analyzer // NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
func NewRspamdAnalyzer() *RspamdAnalyzer { func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
return &RspamdAnalyzer{} return &RspamdAnalyzer{symbols: symbols}
} }
// AnalyzeRspamd extracts and analyzes rspamd results from email headers // AnalyzeRspamd extracts and analyzes rspamd results from email headers
@ -51,13 +53,22 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
return nil return nil
} }
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
_, hasSpamdResult := headers["X-Spamd-Result"]
_, hasRspamdScore := headers["X-Rspamd-Score"]
if !hasSpamdResult && !hasRspamdScore {
return nil
}
result := &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) // Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
if spamdResult, ok := headers["X-Spamd-Result"]; ok { if spamdResult, ok := headers["X-Spamd-Result"]; ok {
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
result.Report = &report
a.parseSpamdResult(spamdResult, result) a.parseSpamdResult(spamdResult, result)
} }
@ -74,6 +85,16 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
result.Server = &server 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. // Derive IsSpam from score vs reject threshold.
if result.Threshold > 0 { if result.Threshold > 0 {
result.IsSpam = result.Score >= result.Threshold result.IsSpam = result.Score >= result.Threshold
@ -111,15 +132,16 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
} }
// Parse symbols: SYMBOL(score)[params] // Parse symbols: SYMBOL(score)[params]
// Each symbol entry is separated by ";" // Each symbol entry is separated by ";", so within each part we use a
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`) // greedy match to capture params that may contain nested brackets.
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
for _, part := range strings.Split(header, ";") { for _, part := range strings.Split(header, ";") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
matches := symbolRe.FindStringSubmatch(part) matches := symbolRe.FindStringSubmatch(part)
if len(matches) > 2 { if len(matches) > 2 {
name := matches[1] name := matches[1]
score, _ := strconv.ParseFloat(matches[2], 64) score, _ := strconv.ParseFloat(matches[2], 64)
sym := api.RspamdSymbol{ sym := api.SpamTestDetail{
Name: name, Name: name,
Score: float32(score), 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)
}

414
pkg/analyzer/rspamd_test.go Normal file
View file

@ -0,0 +1,414 @@
// 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 (
"bytes"
"net/mail"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
analyzer := NewRspamdAnalyzer(nil)
email := &EmailMessage{Header: make(mail.Header)}
result := analyzer.AnalyzeRspamd(email)
if result != nil {
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
}
}
func TestParseSpamdResult(t *testing.T) {
tests := []struct {
name string
header string
expectedScore float32
expectedThreshold float32
expectedIsSpam bool
expectedSymbols map[string]float32
expectedSymParams map[string]string
}{
{
name: "Clean email negative score",
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
expectedScore: -3.91,
expectedThreshold: 15.00,
expectedIsSpam: false,
expectedSymbols: map[string]float32{
"DATE_IN_PAST": 0.10,
"ALL_TRUSTED": -1.00,
},
expectedSymParams: map[string]string{
"ALL_TRUSTED": "trusted",
},
},
{
name: "Spam email True flag",
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
expectedScore: 16.50,
expectedThreshold: 15.00,
expectedIsSpam: true,
expectedSymbols: map[string]float32{
"BAYES_99": 5.00,
"SPOOFED_SENDER": 3.50,
},
expectedSymParams: map[string]string{
"BAYES_99": "1.00",
},
},
{
name: "Zero threshold uses default",
header: "default: False [1.00 / 0.00]",
expectedScore: 1.00,
expectedThreshold: rspamdDefaultAddHeaderThreshold,
expectedIsSpam: false,
expectedSymbols: map[string]float32{},
},
{
name: "Symbol without params",
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
expectedScore: 2.00,
expectedThreshold: 15.00,
expectedIsSpam: false,
expectedSymbols: map[string]float32{
"MISSING_DATE": 1.00,
},
},
{
name: "Case-insensitive true flag",
header: "default: true [8.00 / 6.00]",
expectedScore: 8.00,
expectedThreshold: 6.00,
expectedIsSpam: true,
expectedSymbols: map[string]float32{},
},
{
name: "Zero threshold with symbols containing nested brackets in params",
header: "default: False [0.90 / 0.00];\n" +
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
expectedScore: 0.90,
expectedThreshold: rspamdDefaultAddHeaderThreshold,
expectedIsSpam: false,
expectedSymbols: map[string]float32{
"ARC_REJECT": 1.00,
"MIME_GOOD": -0.10,
"MIME_TRACE": 0.00,
},
expectedSymParams: map[string]string{
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
"MIME_GOOD": "multipart/alternative,text/plain",
"MIME_TRACE": "0:+,1:+,2:~",
},
},
}
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &api.RspamdResult{
Symbols: make(map[string]api.SpamTestDetail),
}
analyzer.parseSpamdResult(tt.header, result)
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if result.Threshold != tt.expectedThreshold {
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
for symName, expectedScore := range tt.expectedSymbols {
sym, ok := result.Symbols[symName]
if !ok {
t.Errorf("Symbol %s not found", symName)
continue
}
if sym.Score != expectedScore {
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
}
}
for symName, expectedParam := range tt.expectedSymParams {
sym, ok := result.Symbols[symName]
if !ok {
t.Errorf("Symbol %s not found for params check", symName)
continue
}
if sym.Params == nil {
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
} else if *sym.Params != expectedParam {
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
}
}
})
}
}
func TestAnalyzeRspamd(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expectedScore float32
expectedThreshold float32
expectedIsSpam bool
expectedServer *string
expectedSymCount int
}{
{
name: "Full headers clean email",
headers: map[string]string{
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
"X-Rspamd-Score": "-3.91",
"X-Rspamd-Server": "mail.example.com",
},
expectedScore: -3.91,
expectedThreshold: 15.00,
expectedIsSpam: false,
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
expectedSymCount: 1,
},
{
name: "X-Rspamd-Score overrides spamd result score",
headers: map[string]string{
"X-Spamd-Result": "default: False [2.00 / 15.00]",
"X-Rspamd-Score": "3.50",
},
expectedScore: 3.50,
expectedThreshold: 15.00,
expectedIsSpam: false,
},
{
name: "Spam email above threshold",
headers: map[string]string{
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
"X-Rspamd-Score": "16.00",
},
expectedScore: 16.00,
expectedThreshold: 15.00,
expectedIsSpam: true,
expectedSymCount: 1,
},
{
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
headers: map[string]string{
"X-Rspamd-Score": "2.00",
},
expectedScore: 2.00,
expectedIsSpam: false,
},
{
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
headers: map[string]string{
"X-Rspamd-Score": "7.00",
},
expectedScore: 7.00,
expectedIsSpam: true,
},
{
name: "Server header is trimmed",
headers: map[string]string{
"X-Rspamd-Score": "1.00",
"X-Rspamd-Server": " rspamd-01 ",
},
expectedScore: 1.00,
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
},
}
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{Header: make(mail.Header)}
for k, v := range tt.headers {
email.Header[k] = []string{v}
}
result := analyzer.AnalyzeRspamd(email)
if result == nil {
t.Fatal("Expected non-nil result")
}
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
if tt.expectedServer != nil {
if result.Server == nil {
t.Errorf("Server = nil, want %q", *tt.expectedServer)
} else if *result.Server != *tt.expectedServer {
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
}
}
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
}
})
}
}
func TestCalculateRspamdScore(t *testing.T) {
tests := []struct {
name string
result *api.RspamdResult
expectedScore int
expectedGrade string
}{
{
name: "Nil result (rspamd not installed)",
result: nil,
expectedScore: 100,
expectedGrade: "",
},
{
name: "Score well below threshold",
result: &api.RspamdResult{
Score: -3.91,
Threshold: 15.00,
},
expectedScore: 100,
expectedGrade: "A+",
},
{
name: "Score at zero",
result: &api.RspamdResult{
Score: 0,
Threshold: 15.00,
},
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
expectedScore: 100,
expectedGrade: "A",
},
{
name: "Score at threshold (half of 2*threshold)",
result: &api.RspamdResult{
Score: 15.00,
Threshold: 15.00,
},
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
expectedScore: 50,
},
{
name: "Score above 2*threshold",
result: &api.RspamdResult{
Score: 31.00,
Threshold: 15.00,
},
expectedScore: 0,
expectedGrade: "F",
},
{
name: "Score exactly at 2*threshold",
result: &api.RspamdResult{
Score: 30.00,
Threshold: 15.00,
},
// 100 - round(30*100/30) = 100 - 100 = 0
expectedScore: 0,
expectedGrade: "F",
},
}
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, grade := analyzer.CalculateRspamdScore(tt.result)
if score != tt.expectedScore {
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
}
if tt.expectedGrade != "" && grade != tt.expectedGrade {
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
}
})
}
}
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
BAYES_HAM(-3.00)[99%];
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
FROM_HAS_DN(0.00)[];
MIME_GOOD(-0.10)[text/plain];
X-Rspamd-Score: -3.91
X-Rspamd-Server: rspamd-01.example.com
Date: Mon, 09 Mar 2026 10:00:00 +0000
From: sender@example.com
To: test@happydomain.org
Subject: Test email
Message-ID: <test123@example.com>
MIME-Version: 1.0
Content-Type: text/plain
Hello world`
func TestAnalyzeRspamdRealEmail(t *testing.T) {
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
analyzer := NewRspamdAnalyzer(nil)
result := analyzer.AnalyzeRspamd(email)
if result == nil {
t.Fatal("Expected non-nil result")
}
if result.IsSpam {
t.Error("Expected IsSpam=false")
}
if result.Score != -3.91 {
t.Errorf("Score = %v, want -3.91", result.Score)
}
if result.Threshold != 15.00 {
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
}
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
}
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
for _, sym := range expectedSymbols {
if _, ok := result.Symbols[sym]; !ok {
t.Errorf("Symbol %s not found", sym)
}
}
score, _ := analyzer.CalculateRspamdScore(result)
if score != 100 {
t.Errorf("CalculateRspamdScore = %d, want 100", score)
}
}

View file

@ -73,6 +73,8 @@ func ScoreToReportGrade(score int) api.ReportGrade {
// gradeRank returns a numeric rank for a grade (lower = worse) // gradeRank returns a numeric rank for a grade (lower = worse)
func gradeRank(grade string) int { func gradeRank(grade string) int {
switch grade { switch grade {
case "A++":
return 7
case "A+": case "A+":
return 6 return 6
case "A": case "A":

View file

@ -45,6 +45,14 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
return nil return nil
} }
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
_, hasStatus := headers["X-Spam-Status"]
_, hasScore := headers["X-Spam-Score"]
_, hasFlag := headers["X-Spam-Flag"]
if !hasStatus && !hasScore && !hasFlag {
return nil
}
result := &api.SpamAssassinResult{ result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail), TestDetails: make(map[string]api.SpamTestDetail),
} }

2180
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,13 +17,13 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.0", "@eslint/compat": "^2.0.0",
"@eslint/js": "^9.36.0", "@eslint/js": "^10.0.0",
"@hey-api/openapi-ts": "0.86.10", "@hey-api/openapi-ts": "0.86.10",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2", "@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"eslint": "^9.38.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4", "eslint-plugin-svelte": "^3.12.4",
"globals": "^17.0.0", "globals": "^17.0.0",
@ -33,7 +33,7 @@
"svelte-check": "^4.3.2", "svelte-check": "^4.3.2",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^8.44.1", "typescript-eslint": "^8.44.1",
"vite": "^7.1.10", "vite": "^8.0.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"dependencies": { "dependencies": {

View file

@ -13,12 +13,19 @@
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props(); let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
let allRequiredMissing = $derived(
!authentication.spf &&
(!authentication.dkim || authentication.dkim.length === 0) &&
!authentication.dmarc,
);
function getAuthResultClass(result: string, noneIsFail: boolean): string { function getAuthResultClass(result: string, noneIsFail: boolean): string {
switch (result) { switch (result) {
case "pass": case "pass":
case "domain_pass": case "domain_pass":
case "orgdomain_pass": case "orgdomain_pass":
return "text-success"; return "text-success";
case "permerror":
case "error": case "error":
case "fail": case "fail":
case "missing": case "missing":
@ -51,6 +58,7 @@
case "neutral": case "neutral":
case "invalid": case "invalid":
case "null": case "null":
case "permerror":
case "error": case "error":
case "null_smtp": case "null_smtp":
case "null_header": case "null_header":
@ -95,6 +103,28 @@
</span> </span>
</h4> </h4>
</div> </div>
{#if allRequiredMissing}
<div class="card-body border-bottom">
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>No authentication results found.</strong>
<p class="mb-0 mt-1">
This usually means either:
</p>
<ul class="mb-0 mt-1">
<li>
The receiving mail server is not configured to verify email authentication
(no <code>Authentication-Results</code> header was found in the message).
</li>
<li>
The <code>Authentication-Results</code> header exists but the receiver
hostname does not match the configured
<code>--receiver-hostname</code> value.
</li>
</ul>
</div>
</div>
{/if}
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<!-- IPREV --> <!-- IPREV -->
{#if authentication.iprev} {#if authentication.iprev}

View file

@ -1,23 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen"; import type { BlacklistCheck } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score"; import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
import EmailPathCard from "./EmailPathCard.svelte";
import GradeDisplay from "./GradeDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
blacklists: Record<string, BlacklistCheck[]>; blacklists: Record<string, BlacklistCheck[]>;
blacklistGrade?: string; blacklistGrade?: string;
blacklistScore?: number; blacklistScore?: number;
receivedChain?: ReceivedHop[];
} }
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props(); let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
</script> </script>
<div class="card shadow-sm" id="rbl-details"> <div class="card shadow-sm" id="rbl-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}> <div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0 d-flex justify-content-between align-items-center"> <h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
<span> <span>
<i class="bi bi-shield-exclamation me-2"></i> <i class="bi bi-shield-exclamation me-2"></i>
Blacklist Checks Blacklist Checks
@ -35,11 +33,7 @@
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">
{#if receivedChain} <div class="row row-cols-1 row-cols-lg-2 overflow-auto">
<EmailPathCard {receivedChain} />
{/if}
<div class="row row-cols-1 row-cols-lg-2">
{#each Object.entries(blacklists) as [ip, checks]} {#each Object.entries(blacklists) as [ip, checks]}
<div class="col mb-3"> <div class="col mb-3">
<h5 class="text-muted"> <h5 class="text-muted">

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ReceivedHop } from "$lib/api/types.gen"; import type { ReceivedHop } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props { interface Props {
receivedChain: ReceivedHop[]; receivedChain: ReceivedHop[];
@ -9,9 +10,18 @@
</script> </script>
{#if receivedChain && receivedChain.length > 0} {#if receivedChain && receivedChain.length > 0}
<div class="mb-3" id="email-path"> <div class="card shadow-sm" id="email-path">
<h5>Email Path (Received Chain)</h5> <div
<div class="list-group"> class="card-header"
class:bg-white={$theme === "light"}
class:bg-dark={$theme !== "light"}
>
<h4 class="mb-0">
<i class="bi bi-pin-map me-2"></i>
Email Path
</h4>
</div>
<div class="list-group list-group-flush">
{#each receivedChain as hop, i} {#each receivedChain as hop, i}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
@ -30,7 +40,7 @@
: "-"} : "-"}
</small> </small>
</div> </div>
{#if hop.with || hop.id} {#if hop.with || hop.id || hop.from}
<p class="mb-1 small d-flex gap-3"> <p class="mb-1 small d-flex gap-3">
{#if hop.with} {#if hop.with}
<span> <span>

View file

@ -21,6 +21,11 @@
); );
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0); const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
let showDifferent = $state(false);
const differentCount = $derived(
ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0,
);
</script> </script>
{#if ptrRecords && ptrRecords.length > 0} {#if ptrRecords && ptrRecords.length > 0}
@ -63,15 +68,31 @@
<div class="mb-2"> <div class="mb-2">
<strong>Forward Resolution (A/AAAA):</strong> <strong>Forward Resolution (A/AAAA):</strong>
{#each ptrForwardRecords as ip} {#each ptrForwardRecords as ip}
<div class="d-flex gap-2 align-items-center mt-1"> {#if ip === senderIp || !fcrDnsIsValid || showDifferent}
{#if senderIp && ip === senderIp} <div class="d-flex gap-2 align-items-center mt-1">
<span class="badge bg-success">Match</span> {#if senderIp && ip === senderIp}
{:else} <span class="badge bg-success">Match</span>
<span class="badge bg-warning">Different</span> {:else}
{/if} <span class="badge bg-secondary">Different</span>
<code>{ip}</code> {/if}
</div> <code>{ip}</code>
</div>
{/if}
{/each} {/each}
{#if fcrDnsIsValid && differentCount > 0}
<div class="mt-1">
<button
class="btn btn-link btn-sm p-0 text-muted"
onclick={() => (showDifferent = !showDifferent)}
>
{#if showDifferent}
Hide other IPs
{:else}
Show {differentCount} other IP{differentCount > 1 ? 's' : ''} (not the sender)
{/if}
</button>
</div>
{/if}
</div> </div>
{#if fcrDnsIsValid} {#if fcrDnsIsValid}
<div class="alert alert-success mb-0 mt-2"> <div class="alert alert-success mb-0 mt-2">

View file

@ -17,8 +17,7 @@
const effectiveAction = $derived.by(() => { const effectiveAction = $derived.by(() => {
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15; const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
if (rspamd.score >= rejectThreshold) if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" };
return { label: "Reject", cls: "bg-danger" };
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD) if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
return { label: "Add header", cls: "bg-warning text-dark" }; return { label: "Add header", cls: "bg-warning text-dark" };
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD) if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
@ -31,7 +30,7 @@
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}"> <div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
<h4 class="mb-0 d-flex justify-content-between align-items-center"> <h4 class="mb-0 d-flex justify-content-between align-items-center">
<span> <span>
<i class="bi bi-shield-exclamation me-2"></i> <i class="bi bi-bug me-2"></i>
rspamd Analysis rspamd Analysis
</span> </span>
<span> <span>
@ -76,7 +75,7 @@
<tr> <tr>
<th>Symbol</th> <th>Symbol</th>
<th class="text-end">Score</th> <th class="text-end">Score</th>
<th>Parameters</th> <th>Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -88,7 +87,14 @@
? "table-success" ? "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"> <td class="text-end">
<span <span
class={symbol.score > 0 class={symbol.score > 0
@ -100,7 +106,7 @@
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
</span> </span>
</td> </td>
<td class="small text-muted">{symbol.params ?? ""}</td> <td class="small text-muted">{symbol.description ?? ""}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@ -108,10 +114,32 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if rspamd.report}
<details class="mt-3">
<summary class="cursor-pointer fw-bold">Raw Report</summary>
<pre
class="mt-2 small {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} p-3 rounded">{rspamd.report}</pre>
</details>
{/if}
</div> </div>
</div> </div>
<style> <style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
/* Darker table colors in dark mode */ /* Darker table colors in dark mode */
:global([data-bs-theme="dark"]) .table-warning { :global([data-bs-theme="dark"]) .table-warning {
--bs-table-bg: rgba(255, 193, 7, 0.2); --bs-table-bg: rgba(255, 193, 7, 0.2);

View file

@ -25,16 +25,32 @@
// Email sender information // Email sender information
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender"; const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0; const hasDkim =
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass"); report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0;
const dkimPassed =
report.authentication?.dkim &&
report.authentication?.dkim.length > 0 &&
report.authentication?.dkim?.some((d) => d.result === "pass");
segments.push({ text: "Received a " }); segments.push({ text: "Received a " });
segments.push({ segments.push({
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed", text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
highlight: { color: dkimPassed ? "good" : "danger", bold: true }, highlight: {
link: "#authentication-dkim", color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
bold: true,
},
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
}); });
segments.push({ text: " email from " }); segments.push({ text: " email" });
if (hasDkim && !dkimPassed) {
segments.push({ text: " with " });
segments.push({
text: "an invalid signature",
highlight: { color: "danger", bold: true },
link: "#authentication-dkim",
});
}
segments.push({ text: " from " });
segments.push({ segments.push({
text: mailFrom, text: mailFrom,
highlight: { emphasis: true }, highlight: { emphasis: true },
@ -113,7 +129,7 @@
} else if (spfResult === "temperror" || spfResult === "permerror") { } else if (spfResult === "temperror" || spfResult === "permerror") {
segments.push({ segments.push({
text: "encountered an error", text: "encountered an error",
highlight: { color: "warning", bold: true }, highlight: { color: "danger", bold: true },
link: "#authentication-spf", link: "#authentication-spf",
}); });
segments.push({ text: ", check your SPF record configuration" }); segments.push({ text: ", check your SPF record configuration" });
@ -331,7 +347,7 @@
highlight: { color: "good", bold: true }, highlight: { color: "good", bold: true },
link: "#dns-bimi", link: "#dns-bimi",
}); });
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) { if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
segments.push({ text: " declined to participate" }); segments.push({ text: " declined to participate" });
} else if (bimiResult?.result === "fail") { } else if (bimiResult?.result === "fail") {
segments.push({ text: " but " }); segments.push({ text: " but " });

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { BlacklistCheck } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
whitelists: Record<string, BlacklistCheck[]>;
}
let { whitelists }: Props = $props();
</script>
<div class="card shadow-sm" id="dnswl-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
<span>
<i class="bi bi-shield-check me-2"></i>
Whitelist Checks
</span>
<span class="badge bg-info text-white">Informational</span>
</h4>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
DNS whitelists identify trusted senders. Being listed here is a positive signal, but has
no impact on the overall score.
</p>
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
{#each Object.entries(whitelists) as [ip, checks]}
<div class="col mb-3">
<h5 class="text-muted">
<i class="bi bi-hdd-network me-1"></i>
{ip}
</h5>
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each checks as check}
<tr>
<td title={check.response || "-"}>
<span
class="badge"
class:bg-success={check.listed}
class:bg-dark={check.error}
class:bg-secondary={!check.listed && !check.error}
>
{check.error
? "Error"
: check.listed
? "Listed"
: "Not listed"}
</span>
</td>
<td><code>{check.rbl}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
</div>
</div>

View file

@ -24,3 +24,4 @@ export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
export { default as SummaryCard } from "./SummaryCard.svelte"; export { default as SummaryCard } from "./SummaryCard.svelte";
export { default as TinySurvey } from "./TinySurvey.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte";
export { default as WhitelistCard } from "./WhitelistCard.svelte";

View file

@ -25,6 +25,7 @@ interface AppConfig {
report_retention?: number; report_retention?: number;
survey_url?: string; survey_url?: string;
custom_logo_url?: string; custom_logo_url?: string;
rbls?: string[];
} }
const defaultConfig: AppConfig = { const defaultConfig: AppConfig = {

View file

@ -26,7 +26,7 @@ const getInitialTheme = () => {
if (!browser) return "light"; if (!browser) return "light";
const stored = localStorage.getItem("theme"); const stored = localStorage.getItem("theme");
if (stored) return stored; if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}; };

View file

@ -3,7 +3,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { checkBlacklist } from "$lib/api"; import { checkBlacklist } from "$lib/api";
import type { BlacklistCheckResponse } from "$lib/api/types.gen"; import type { BlacklistCheckResponse } from "$lib/api/types.gen";
import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components"; import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
let ip = $derived($page.params.ip); let ip = $derived($page.params.ip);
@ -28,7 +28,7 @@
}); });
if (response.response.ok) { if (response.response.ok) {
result = response.data; result = response.data ?? null;
} else if (response.error) { } else if (response.error) {
error = response.error.message || "Failed to check IP address"; error = response.error.message || "Failed to check IP address";
} }
@ -122,8 +122,8 @@
> >
<p class="mb-0 mt-1 small"> <p class="mb-0 mt-1 small">
This IP address is listed on {result.listed_count} of This IP address is listed on {result.listed_count} of
{result.checks.length} checked blacklist{result {result.blacklists.length} checked blacklist{result
.checks.length > 1 .blacklists.length > 1
? "s" ? "s"
: ""}. : ""}.
</p> </p>
@ -150,12 +150,23 @@
</div> </div>
</div> </div>
<!-- Blacklist Results Card --> <div class="row">
<BlacklistCard <!-- Blacklist Results Card -->
blacklists={{ [result.ip]: result.checks }} <div class="col col-lg-6">
blacklistScore={result.score} <BlacklistCard
blacklistGrade={result.grade} blacklists={{ [result.ip]: result.blacklists }}
/> blacklistScore={result.score}
blacklistGrade={result.grade}
/>
</div>
<!-- Whitelist Results Card -->
{#if result.whitelists && result.whitelists.length > 0}
<div class="col col-lg-6">
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
</div>
{/if}
</div>
<!-- Information Card --> <!-- Information Card -->
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">

View file

@ -130,7 +130,7 @@
<div class="d-flex justify-content-end me-lg-5 mt-3"> <div class="d-flex justify-content-end me-lg-5 mt-3">
<TinySurvey <TinySurvey
class="bg-primary-subtle rounded-4 p-3 text-center" class="bg-primary-subtle rounded-4 p-3 text-center"
source={"rbl-" + result.ip} source={"domain-" + result.domain}
/> />
</div> </div>
</div> </div>

View file

@ -3,12 +3,13 @@
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { getReport, getTest, reanalyzeReport } from "$lib/api"; import { getReport, getTest, reanalyzeReport } from "$lib/api";
import type { Report, Test } from "$lib/api/types.gen"; import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
import { import {
AuthenticationCard, AuthenticationCard,
BlacklistCard, BlacklistCard,
ContentAnalysisCard, ContentAnalysisCard,
DnsRecordsCard, DnsRecordsCard,
EmailPathCard,
ErrorDisplay, ErrorDisplay,
HeaderAnalysisCard, HeaderAnalysisCard,
PendingState, PendingState,
@ -17,8 +18,11 @@
SpamAssassinCard, SpamAssassinCard,
SummaryCard, SummaryCard,
TinySurvey, TinySurvey,
WhitelistCard,
} from "$lib/components"; } from "$lib/components";
type BlacklistRecords = Record<string, BlacklistCheck[]>;
let testId = $derived(page.params.test); let testId = $derived(page.params.test);
let test = $state<Test | null>(null); let test = $state<Test | null>(null);
let report = $state<Report | null>(null); let report = $state<Report | null>(null);
@ -291,6 +295,15 @@
</div> </div>
</div> </div>
<!-- Received Chain -->
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
<div class="row mb-4" id="received-chain">
<div class="col-12">
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
</div>
</div>
{/if}
<!-- DNS Records --> <!-- DNS Records -->
{#if report.dns_results} {#if report.dns_results}
<div class="row mb-4" id="dns"> <div class="row mb-4" id="dns">
@ -321,17 +334,45 @@
{/if} {/if}
<!-- Blacklist Checks --> <!-- Blacklist Checks -->
{#if report.blacklists && Object.keys(report.blacklists).length > 0} {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
<div class="row mb-4" id="blacklist"> <BlacklistCard
<div class="col-12"> {blacklists}
<BlacklistCard blacklistGrade={report.summary?.blacklist_grade}
blacklists={report.blacklists} blacklistScore={report.summary?.blacklist_score}
blacklistGrade={report.summary?.blacklist_grade} />
blacklistScore={report.summary?.blacklist_score} {/snippet}
receivedChain={report.header_analysis?.received_chain}
/> <!-- Whitelist Checks -->
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
<WhitelistCard {whitelists} />
{/snippet}
<!-- Blacklist & Whitelist Checks -->
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
<div class="row mb-4">
<div class="col-6" id="blacklist">
{@render blacklistChecks(report.blacklists, report)}
</div>
<div class="col-6" id="whitelist">
{@render whitelistChecks(report.whitelists)}
</div> </div>
</div> </div>
{:else}
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
<div class="row mb-4" id="blacklist">
<div class="col-12">
{@render blacklistChecks(report.blacklists, report)}
</div>
</div>
{/if}
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
<div class="row mb-4" id="whitelist">
<div class="col-12">
{@render whitelistChecks(report.whitelists)}
</div>
</div>
{/if}
{/if} {/if}
<!-- Header Analysis --> <!-- Header Analysis -->
@ -352,12 +393,12 @@
{#if report.spamassassin || report.rspamd} {#if report.spamassassin || report.rspamd}
<div class="row mb-4" id="spam"> <div class="row mb-4" id="spam">
{#if report.spamassassin} {#if report.spamassassin}
<div class={report.rspamd ? "col-lg-6 mb-4 mb-lg-0" : "col-12"}> <div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
<SpamAssassinCard spamassassin={report.spamassassin} /> <SpamAssassinCard spamassassin={report.spamassassin} />
</div> </div>
{/if} {/if}
{#if report.rspamd} {#if report.rspamd}
<div class={report.spamassassin ? "col-lg-6" : "col-12"}> <div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
<RspamdCard rspamd={report.rspamd} /> <RspamdCard rspamd={report.rspamd} />
</div> </div>
{/if} {/if}