Compare commits

..

84 commits

Author SHA1 Message Date
9c3109c087 rbl: apply flat 10% penalty for informational list hits
Some checks are pending
continuous-integration/drone/push Build is running
Informational lists previously didn't count toward the score at all.
Now any informational listing applies a flat 10% penalty regardless of
how many of them fire, with the final score clamped at 0.
2026-06-13 17:56:31 +09:00
eb52a1d135 spamassassin: disable EMPTY_MESSAGE penalty for test compatibility 2026-06-13 17:46:10 +09:00
08ef2151e3 rbl: add wl.mailspike.net and iadb.isipp.com to default DNSWL list
Some checks are pending
continuous-integration/drone/push Build is running
2026-06-13 16:23:57 +09:00
2483d49fe9 bimi: show declination hint only when DMARC is enforced
Only display the "Explicitly decline BIMI participation" hint when DMARC
policy is quarantine or reject, as BIMI requires strong DMARC enforcement
to be meaningful.
2026-06-13 16:18:40 +09:00
f88701681f content: treat unreplaced template placeholders as invalid links
URLs like "{unsubscribe}" parse without error in Go, so they were
reported as valid links and even counted as unsubscribe methods just
because they contain the word "unsubscribe".

Detect common un-substituted merge-field syntaxes ({x}, {{x}}, ${x},
*|X|*, %x%, [x], %7Bx%7D), mark such links invalid (Broken status),
exclude them from unsubscribe method detection, and surface them via a
new unreplaced_template content issue.
2026-06-13 16:14:57 +09:00
c3194de6aa Update go.mod
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-12 16:54:29 +09:00
19a000694f chore(deps): update dependency vitest to v4
Some checks are pending
continuous-integration/drone/push Build is pending
2026-06-12 07:52:41 +00:00
7929168174 chore(deps): update module github.com/oapi-codegen/runtime to v1.4.1
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
2026-06-12 04:06:55 +00:00
5c1e08669d chore(deps): update dependency prettier-plugin-svelte to v4
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-12 03:44:49 +00:00
9509fd0a8a chore(deps): update module github.com/getkin/kin-openapi to v0.140.0
Some checks are pending
continuous-integration/drone/push Build is pending
2026-06-12 03:44:27 +00:00
64a2c01b39 chore(deps): update module github.com/oapi-codegen/oapi-codegen/v2 to v2.7.1
Some checks are pending
continuous-integration/drone/push Build is pending
2026-06-12 03:44:25 +00:00
bd9998c80b chore(deps): lock file maintenance
Some checks are pending
continuous-integration/drone/push Build is pending
2026-06-12 03:44:08 +00:00
3f3b5e215c chore(deps): update module golang.org/x/net to v0.56.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is pending
2026-06-10 18:07:07 +00:00
1c2218a779 headers: detect fake reply/forward subject without thread headers
All checks were successful
continuous-integration/drone/push Build is passing
Flag emails where the Subject starts with a Re:/Fwd: prefix (in ~17
languages) but neither References nor In-Reply-To is present, a common
spam/phishing technique to falsely imply an ongoing conversation.
2026-06-06 17:52:22 +09:00
57022129e3 content: fix false-positive suspicious URL detection for email addresses in link text
The domain regex in hasDomainMisalignment matched local-parts like
"john.doe" in "john.doe@example.com" as if they were domain names,
causing legitimate mailto and http links to be incorrectly flagged.
Normalize email addresses in link text to their domain part before
applying the regex.
2026-06-06 17:33:06 +09:00
970cbc02a3 bimi: suggest declination record when no valid BIMI record is found
All checks were successful
continuous-integration/drone/push Build is passing
Show an informational tip with a ready-to-copy declination record
(§4.3.1 of draft-brand-indicators-for-message-identification) so users
who do not intend to publish a logo can explicitly opt out and prevent
mail clients from falling back to a parent-domain record.
2026-06-06 17:16:48 +09:00
d53c1b1e00 tls: surface transport TLS status in email path and authentication
All checks were successful
continuous-integration/drone/push Build is passing
Parse TLS details (version, cipher, bits, cert verification) from the
Postfix Received header parenthetical and expose them per hop, rendered
as a per-hop badge in the Email Path card.

Add an x-tls Authentication-Results result: parse it when present, and
otherwise synthesize it from the inbound hop's TLS info. A negative
result (unencrypted inbound connection) applies a -10 authentication
score penalty and is shown in the Authentication card. Enable the TLS
handler in authentication_milter.

Closes: #40
2026-06-06 16:44:27 +09:00
8e7e56851b postfix: add tlsmgr service to enable STARTTLS
Without tlsmgr, smtpd has no PRNG/entropy source and disables TLS,
rejecting STARTTLS with "454 4.7.0 TLS not available due to local problem".
2026-06-06 16:44:27 +09:00
a65b8084ee dns: add ReturnOK check for sender domain reachability
Verify that the From and Return-Path domains can actually receive replies
and bounces, mirroring Fastmail's authentication_milter ReturnOK handler.
Each domain is checked for MX records, falling back to A/AAAA (implicit MX)
and then to the organizational domain, yielding a pass/warn/fail status.
Adds return_ok to DNSResults, a 10-point DNS sub-score penalty per domain
that is wholly unreachable, and a new "Return Address Reachability" card.
2026-06-06 16:44:24 +09:00
e168446b44 dns: add HELO/PTR consistency check
Compare the HELO/EHLO hostname announced by the sending server (first
Received hop) against the sender IP's PTR records, surfacing the same
signal as x-ptr/policy.ptr in Authentication-Results. Adds helo_hostname
and helo_ptr_match to DNSResults, applies a 15-point PTR sub-score
penalty on mismatch, and displays the result in a new HELO/PTR
Consistency card.
2026-06-06 16:13:34 +09:00
27dcb1b0c3 docker: Listen both in ipv4 and ipv6 2026-06-06 15:16:48 +09:00
0b76d51d2b chore(deps): update module golang.org/x/crypto to v0.52.0 [security]
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 11:10:02 +09:00
5f0b5b62d9 chore(deps): update module golang.org/x/net to v0.55.0 [security]
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 10:29:08 +09:00
96c3a6ea0d chore(deps): update module github.com/jackc/pgx/v5 to v5.9.2 [security]
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-03 23:38:12 +09:00
7953dfc3ed analyzer: strip resolver address from DNS lookup error messages
Some checks are pending
continuous-integration/drone/push Build is running
Wrap user-facing lookup errors through a new formatDNSError helper that
clears net.DNSError.Server so the " on <addr>" suffix no longer leaks the
upstream resolver (e.g. "on 127.0.0.11:53") to end users.

Closes: https://framagit.org/happyDomain/happydeliver/-/work_items/2
2026-06-03 23:06:19 +09:00
b3b1a094de dmarc: refactor parseDMARCRecord to use shared tag parser and eliminate helper methods
All checks were successful
continuous-integration/drone/push Build is passing
Replace per-field regex extractor methods with a single parseDKIMTags call,
removing eight redundant private methods and unifying DMARC tag parsing with
the existing DKIM tag parser. Tests are updated to drive through parseDMARCRecord
instead of the removed helpers, and the NP scoring logic is corrected to award
+15/−15 symmetrically like the SP scoring path.
2026-05-18 21:02:53 +08:00
809bca02e4 dmarc: implement DMARCbis DNS Tree Walk and new tag support
Replace RFC 7489 PSL-based org-domain lookup and RFC 9091 PSD DMARC
fallback with the DMARCbis DNS Tree Walk algorithm (max 8 queries,
8-label shortcut, TLD records require psd=y). Add parsing for the new
t= (test mode), psd= (y/n/u), and deprecated tag detection (pct, rf,
ri). Update validateDMARC to accept p=-absent records with rua= per
DMARCbis §4.7. Score t=y by downgrading effective policy one level.

Surface user-facing advisories in DmarcRecordDisplay: deprecation
warnings for pct=/rf=/ri=, test mode explanation with per-policy
impact, and PSD/org-domain boundary notices.
2026-05-18 20:57:31 +08:00
1b8627ef86 dkim: expose algorithm, hash list, and key size in DKIM record analysis
Parse k=, h=, a= tags and derive RSA key bit-length from the public key
so consumers can detect weak configurations (SHA-1, short keys).
Scoring now penalises rsa-sha1 (cap 60), RSA <1024 bit (cap 25), and
RSA <2048 bit (cap 75); Ed25519 receives no penalty.

Fixes: #37
2026-05-18 20:57:31 +08:00
369a13526f analyzer: correct auth scoring weights, x-aligned-from penalty, and RBL divide-by-zero 2026-05-18 20:57:31 +08:00
3161e392e8 dmarc: add support for np= non-existent subdomain policy tag
Implements parsing, scoring, CLI output, and UI display for the DMARC
np= tag (DMARCbis draft-ietf-dmarc-dmarcbis), which controls policy for
NXDOMAIN subdomains independently of sp=. The score deducts 15 points
from the base and awards them back when np= is absent (good default) or
its strength is equal to or stricter than the effective sp=/p= policy.
2026-05-18 17:03:58 +08:00
1516991057 dmarc: implement RFC 7489 org-domain fallback and RFC 9091 PSD DMARC
DMARC lookup now follows the full RFC 7489 §6.6.3 fallback chain: exact
From domain → organizational domain (eTLD+1 via PSL) → public suffix
domain (RFC 9091, only when psd=y is present). DNS errors abort
immediately without triggering fallback; NXDOMAIN and missing v=DMARC1
records do trigger it. The found domain is exposed in the new
DMARCRecord.domain field for reporting purposes.

Also promote getOrganizationalDomain to a package-level function so both
HeaderAnalyzer and DNSAnalyzer can share it, and fix pre-existing
rbl_test.go compilation errors and stale score expectations.

Closes: #98
2026-05-18 17:03:58 +08:00
0de67af847 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-18 00:08:25 +00:00
e324e6cbf9 chore(deps): update module github.com/oapi-codegen/runtime to v1.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-16 11:17:18 +08:00
3e53fae713 fix(docker): install perl-cryptx via apk to fix arm64 build
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-16 10:52:52 +08:00
b3137f7d37 fix(docker): explicitly install Mail::DKIM on arm64 builds
Some checks failed
continuous-integration/drone/push Build is failing
On arm64, cpanm does not automatically resolve Mail::DKIM as a
transitive dependency of Mail::Milter::Authentication, causing the
build to fail. Install it explicitly before Mail::Milter::Authentication.
2026-05-15 22:05:50 +08:00
bfe6ff81fa Add local unbound resolver for up-to-date DNS information
Some checks failed
continuous-integration/drone/push Build is failing
Fixes: #30
2026-05-15 21:24:24 +08:00
5ffc731297 Update go deps
Some checks are pending
continuous-integration/drone/push Build is running
2026-05-12 08:55:53 +08:00
454da476eb chore(deps): update module github.com/getkin/kin-openapi to v0.138.0
Some checks failed
continuous-integration/drone/push Build is failing
2026-05-10 14:25:17 +00:00
eaab446504 chore(deps): update module golang.org/x/net to v0.54.0
Some checks are pending
continuous-integration/drone/push Build is pending
2026-05-10 14:23:48 +00:00
cf63276a07 chore(deps): update dependency typescript to v6
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is pending
2026-05-05 22:07:20 +00:00
09d777634c Readd missing go deps
Some checks failed
continuous-integration/drone/push Build is failing
2026-05-04 11:15:53 +08:00
15120d8598 chore(deps): update module golang.org/x/net to v0.53.0
Some checks are pending
continuous-integration/drone/push Build is running
2026-05-04 03:14:35 +00:00
6f6211e833 chore(deps): update module github.com/oapi-codegen/oapi-codegen/v2 to v2.7.0
Some checks are pending
continuous-integration/drone/push Build is running
2026-05-04 03:14:23 +00:00
42cf6f450d chore(deps): update module github.com/jgltechnologies/gin-rate-limit to v1.5.8
Some checks are pending
continuous-integration/drone/push Build is running
2026-05-04 03:13:54 +00:00
31a27c120b Add instruction to use with new happyDomain checker
Some checks are pending
continuous-integration/drone/push Build is pending
2026-04-28 19:01:22 +07:00
396c51974a Extract OpenAPI schemas to separate file and move models to internal/model package
All checks were successful
continuous-integration/drone/push Build is passing
Split api/openapi.yaml schemas into api/schemas.yaml so structs can be
generated independently from the API server code. Models now generate
into internal/model/ via oapi-codegen, with the server referencing them
through import-mapping. Moved PtrTo helper to internal/utils and removed
storage.ReportSummary in favor of model.TestSummary.
2026-04-09 18:36:27 +07:00
3eec5ce966 Remove unused xAlignedFrom prop from HeaderAnalysisCard
Some checks are pending
continuous-integration/drone/push Build is pending
2026-04-09 18:05:11 +07:00
7422f6ed0a Add paginated test history listing with disable option
Add GET /tests endpoint returning lightweight test summaries (grade,
score, domain, date) with pagination, using database-level JSON
extraction to avoid loading full report blobs. The feature can be
disabled with --disable-test-list flag. Frontend includes a new
/tests/ page with table view and a conditional "History" navbar link.

Fixes: https://github.com/happyDomain/happydeliver/issues/12
2026-04-09 18:05:06 +07:00
e540377bd9 Don't penalize non iprev result nor aligned-from if non-existant
All checks were successful
continuous-integration/drone/push Build is passing
Bug: https://github.com/happyDomain/happydeliver/issues/11
2026-03-27 17:57:48 +07:00
16b7dcb057 Incorporate DNSWL (whitelist) grade into blacklist scoring
All checks were successful
continuous-integration/drone/push Build is passing
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
100 changed files with 14434 additions and 3861 deletions

2
.gitignore vendored
View file

@ -26,5 +26,5 @@ logs/
*.sqlite3
# OpenAPI generated files
internal/api/models.gen.go
internal/api/server.gen.go
internal/model/types.gen.go

View file

@ -49,6 +49,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
perl-crypt-openssl-random \
perl-crypt-openssl-verify \
perl-crypt-openssl-x509 \
perl-cryptx \
perl-dbd-sqlite \
perl-dbi \
perl-email-address-xs \
@ -75,6 +76,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
ln -s /usr/bin/ld /bin/ld
RUN cpanm --notest Mail::SPF && \
cpanm --notest Mail::DKIM && \
cpanm --notest Mail::Milter::Authentication
RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \
@ -100,6 +102,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
perl-crypt-openssl-random \
perl-crypt-openssl-verify \
perl-crypt-openssl-x509 \
perl-cryptx \
perl-dbd-sqlite \
perl-dbi \
perl-email-address-xs \
@ -170,7 +173,13 @@ RUN chmod +x /entrypoint.sh
EXPOSE 25 8080
# 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 ["/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, ...
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
@ -262,6 +279,33 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
## Use with happyDomain
happyDeliver can be driven by [happyDomain](https://happydomain.org) through
the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver)
plugin, so the deliverability of a domain you manage is monitored alongside
its DNS and inbound SMTP posture.
How it works:
1. Attach the **Outbound deliverability** checker to the mail service of a zone
in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`;
operators can configure a default instance globally.
2. On each run, the checker calls `POST /api/test` to allocate a fresh
recipient address, prompts the user (or an automated sender) to mail it from
the tested domain, then polls `GET /api/test/{id}` until the report is
ready.
3. The structured report from `GET /api/report/{id}` is translated into
happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam
score, blacklists and headers, plus an overall score threshold
(`min_score`/`warn_score`).
4. Runs repeat on a configurable interval so a regression in deliverability (a
new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...)
surfaces as a domain-level alert in happyDomain.
See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver)
for build instructions and the full list of run options.
## Scoring System
The deliverability score is calculated from A to F based on:

View file

@ -1,5 +1,9 @@
package: api
package: model
generate:
models: true
embedded-spec: false
output: internal/api/models.gen.go
embedded-spec: true
output: internal/model/types.gen.go
output-options:
skip-prune: true
import-mapping:
./schemas.yaml: "-"

View file

@ -1,5 +1,8 @@
package: api
generate:
gin-server: true
models: true
embedded-spec: true
output: internal/api/server.gen.go
import-mapping:
./schemas.yaml: git.happydns.org/happyDeliver/internal/model

File diff suppressed because it is too large Load diff

1319
api/schemas.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,18 @@
services:
unbound:
image: alpinelinux/unbound
restart: unless-stopped
configs:
- source: unbound_conf
target: /etc/unbound/unbound.conf
uid: "100"
gid: "101"
networks:
default:
ipv4_address: 172.28.0.53
happydeliver:
build:
context: .
@ -24,8 +38,41 @@ services:
# Log files
- ./logs:/var/log/happydeliver
dns:
- 172.28.0.53
restart: unless-stopped
configs:
unbound_conf:
content: |
server:
verbosity: 1
interface: 0.0.0.0
port: 53
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
access-control: 127.0.0.0/8 allow
access-control: 172.28.0.0/24 allow
# Short cache for a testing resolver
cache-max-ttl: 60
# Buffers: let the system decide
so-sndbuf: 0
so-rcvbuf: 0
# Trust anchor (static, ships with the image)
trust-anchor-file: "/etc/unbound/root.key"
volumes:
data:
logs:
networks:
default:
ipam:
config:
- subnet: 172.28.0.0/24

View file

@ -110,14 +110,38 @@ Default configuration for the Docker environment:
The container accepts these environment variables:
- `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
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
**Required volumes:**

View file

@ -52,6 +52,8 @@
"PTR" : {},
"TLS" : {},
"SenderID" : {
"hide_none" : 1
},

View file

@ -7,7 +7,7 @@ myhostname = __HOSTNAME__
mydomain = __DOMAIN__
myorigin = $mydomain
inet_interfaces = all
inet_protocols = ipv4
inet_protocols = all
# Recipient settings
mydestination = localhost.$mydomain, localhost
@ -36,5 +36,8 @@ smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination
# TLS - record the negotiated cipher/protocol in the Received: header
smtpd_tls_received_header = yes
# Logging
debug_peer_level = 2

View file

@ -3,6 +3,9 @@
# SMTP service
smtp inet n - n - - smtpd
# TLS session cache and PRNG manager (required for STARTTLS)
tlsmgr unix - - n 1000? 1 tlsmgr
# Pickup service
pickup unix n - n 60 1 pickup

View file

@ -59,3 +59,6 @@ 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
# emails with no text are common for tests, don't penalize them
score EMPTY_MESSAGE 0

View file

@ -21,5 +21,5 @@
package main
//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml
//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml

53
go.mod
View file

@ -1,15 +1,15 @@
module git.happydns.org/happyDeliver
go 1.24.6
go 1.25.0
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/JGLTechnologies/gin-rate-limit v1.5.8
github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.133.0
github.com/gin-gonic/gin v1.11.0
github.com/getkin/kin-openapi v0.140.0
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.1.2
golang.org/x/net v0.50.0
github.com/oapi-codegen/runtime v1.4.1
golang.org/x/net v0.56.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
@ -27,8 +27,8 @@ require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
@ -36,44 +36,41 @@ require (
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 // indirect
github.com/oasdiff/yaml v0.1.0 // indirect
github.com/oasdiff/yaml3 v0.0.13 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.19.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // 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
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

120
go.sum
View file

@ -1,5 +1,5 @@
github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww=
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
@ -22,10 +22,13 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
@ -38,18 +41,18 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
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-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
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/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -59,8 +62,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@ -90,16 +91,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
@ -114,8 +113,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
@ -125,19 +122,19 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
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/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
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/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 h1:a7Ab7YlpqkVG5HKrTaeFstm32Z5QOnyjnbsCO0jiMYM=
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw=
github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@ -152,24 +149,25 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M=
github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@ -191,21 +189,27 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
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/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
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/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -213,13 +217,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-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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -235,21 +239,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-20211216021012-1d35b9e2eb4e/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.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.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-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.3/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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -31,6 +31,7 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version"
@ -40,8 +41,8 @@ import (
// This interface breaks the circular dependency with pkg/analyzer
type EmailAnalyzer interface {
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error)
AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error)
}
// APIHandler implements the ServerInterface for handling API requests
@ -79,11 +80,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
)
// Return response
c.JSON(http.StatusCreated, TestResponse{
c.JSON(http.StatusCreated, model.TestResponse{
Id: base32ID,
Email: openapi_types.Email(email),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the given address"),
Status: model.TestResponseStatusPending,
Message: utils.PtrTo("Send your test email to the given address"),
})
}
@ -93,10 +94,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -104,20 +105,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to check test status",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
// Determine status based on report existence
var apiStatus TestStatus
var apiStatus model.TestStatus
if reportExists {
apiStatus = TestStatusAnalyzed
apiStatus = model.TestStatusAnalyzed
} else {
apiStatus = TestStatusPending
apiStatus = model.TestStatusPending
}
// Generate test email address using Base32-encoded UUID
@ -127,7 +128,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
h.config.Email.Domain,
)
c.JSON(http.StatusOK, Test{
c.JSON(http.StatusOK, model.Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
@ -140,10 +141,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -151,16 +152,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Report not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve report",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -175,10 +176,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -186,16 +187,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -209,10 +210,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -221,16 +222,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve email",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -238,20 +239,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Re-analyze the email using the current analyzer
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
c.JSON(http.StatusInternalServerError, model.Error{
Error: "analysis_error",
Message: "Failed to re-analyze email",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
// Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
c.JSON(http.StatusInternalServerError, Error{
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to update report",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -267,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists
dbStatus := StatusComponentsDatabaseUp
dbStatus := model.StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = StatusComponentsDatabaseDown
dbStatus = model.StatusComponentsDatabaseDown
}
// Determine overall status
overallStatus := Healthy
if dbStatus == StatusComponentsDatabaseDown {
overallStatus = Unhealthy
overallStatus := model.Healthy
if dbStatus == model.StatusComponentsDatabaseDown {
overallStatus = model.Unhealthy
}
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
mtaStatus := model.StatusComponentsMtaUp
c.JSON(http.StatusOK, model.Status{
Status: overallStatus,
Version: version.Version,
Components: &struct {
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
Database *model.StatusComponentsDatabase `json:"database,omitempty"`
Mta *model.StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
@ -296,14 +297,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
// TestDomain performs synchronous domain analysis
// (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) {
var request DomainTestRequest
var request model.DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
@ -312,28 +313,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
var responseGrade DomainTestResponseGrade
var responseGrade model.DomainTestResponseGrade
switch grade {
case "A+":
responseGrade = DomainTestResponseGradeA
responseGrade = model.DomainTestResponseGradeA
case "A":
responseGrade = DomainTestResponseGradeA1
responseGrade = model.DomainTestResponseGradeA1
case "B":
responseGrade = DomainTestResponseGradeB
responseGrade = model.DomainTestResponseGradeB
case "C":
responseGrade = DomainTestResponseGradeC
responseGrade = model.DomainTestResponseGradeC
case "D":
responseGrade = DomainTestResponseGradeD
responseGrade = model.DomainTestResponseGradeD
case "E":
responseGrade = DomainTestResponseGradeE
responseGrade = model.DomainTestResponseGradeE
case "F":
responseGrade = DomainTestResponseGradeF
responseGrade = model.DomainTestResponseGradeF
default:
responseGrade = DomainTestResponseGradeF
responseGrade = model.DomainTestResponseGradeF
}
// Build response
response := DomainTestResponse{
response := model.DomainTestResponse{
Domain: request.Domain,
Score: score,
Grade: responseGrade,
@ -346,37 +347,79 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
// CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
var request BlacklistCheckRequest
var request model.BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
// 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 {
c.JSON(http.StatusBadRequest, Error{
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_ip",
Message: "Invalid IP address",
Details: stringPtr(err.Error()),
Details: utils.PtrTo(err.Error()),
})
return
}
// Build response
response := BlacklistCheckResponse{
response := model.BlacklistCheckResponse{
Ip: request.Ip,
Checks: checks,
Blacklists: checks,
Whitelists: &whitelists,
ListedCount: listedCount,
Score: score,
Grade: BlacklistCheckResponseGrade(grade),
Grade: model.BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
}
// ListTests returns a paginated list of test summaries
// (GET /tests)
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
if h.config.DisableTestList {
c.JSON(http.StatusForbidden, model.Error{
Error: "feature_disabled",
Message: "Test listing is disabled on this instance",
})
return
}
offset := 0
limit := 20
if params.Offset != nil {
offset = *params.Offset
}
if params.Limit != nil {
limit = *params.Limit
if limit > 100 {
limit = 100
}
}
tests, total, err := h.storage.ListReportSummaries(offset, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to list tests",
Details: utils.PtrTo(err.Error()),
})
return
}
c.JSON(http.StatusOK, model.TestListResponse{
Tests: tests,
Total: int(total),
Offset: offset,
Limit: limit,
})
}

View file

@ -202,6 +202,9 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze
if dns.DmarcRecord.SubdomainPolicy != nil {
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
}
if dns.DmarcRecord.NonexistentSubdomainPolicy != nil {
fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy)
}
fmt.Fprintln(writer)
if dns.DmarcRecord.Record != nil {
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)

View file

@ -34,14 +34,17 @@ func declareFlags(o *Config) {
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.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.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -34,6 +34,11 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
func getHostname() string {
h, _ := os.Hostname()
return h
}
// Config represents the application configuration
type Config struct {
DevProxy string
@ -45,6 +50,7 @@ type Config struct {
RateLimit uint // API rate limit (requests per second per IP)
SurveyURL url.URL // URL for user feedback survey
CustomLogoURL string // URL for custom logo image in the web UI
DisableTestList bool // Disable the public test listing endpoint
}
// DatabaseConfig contains database connection settings
@ -58,6 +64,7 @@ type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
ReceiverHostname string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
@ -67,6 +74,7 @@ type AnalysisConfig struct {
RBLs []string
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
@ -84,6 +92,7 @@ func DefaultConfig() *Config {
Domain: "happydeliver.local",
TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525",
ReceiverHostname: getHostname(),
},
Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second,

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)
// 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
reportJSON, err := json.Marshal(result.Report)
if err != nil {

View file

@ -30,6 +30,9 @@ import (
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
var (
@ -45,6 +48,7 @@ type Storage interface {
ReportExists(testID uuid.UUID) (bool, error)
UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error)
ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
// Close closes the database connection
Close() error
@ -139,6 +143,72 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
return result.RowsAffected, nil
}
// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
type reportSummaryRow struct {
TestID uuid.UUID
Score int
Grade string
FromDomain string
CreatedAt time.Time
}
// ListReportSummaries returns a paginated list of lightweight report summaries
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
var total int64
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
}
if total == 0 {
return []model.TestSummary{}, 0, nil
}
var selectExpr string
switch s.db.Dialector.Name() {
case "postgres":
selectExpr = `test_id, ` +
`(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
`created_at`
case "sqlite":
selectExpr = `test_id, ` +
`json_extract(report_json, '$.score') as score, ` +
`json_extract(report_json, '$.grade') as grade, ` +
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
`created_at`
default:
return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
}
var rows []reportSummaryRow
err := s.db.Model(&Report{}).
Select(selectExpr).
Order("created_at DESC").
Offset(offset).
Limit(limit).
Scan(&rows).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
}
summaries := make([]model.TestSummary, 0, len(rows))
for _, r := range rows {
s := model.TestSummary{
TestId: utils.UUIDToBase32(r.TestID),
Score: r.Score,
Grade: model.TestSummaryGrade(r.Grade),
CreatedAt: r.CreatedAt,
}
if r.FromDomain != "" {
s.FromDomain = utils.PtrTo(r.FromDomain)
}
summaries = append(summaries, s)
}
return summaries, total, nil
}
// Close closes the database connection
func (s *DBStorage) Close() error {
sqlDB, err := s.db.DB()

View file

@ -1,5 +1,5 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@ -19,11 +19,7 @@
// 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 api
func stringPtr(s string) *string {
return &s
}
package utils
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {

View file

@ -28,7 +28,7 @@ import (
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/config"
)
@ -41,11 +41,13 @@ type EmailAnalyzer struct {
// NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator(
cfg.Email.ReceiverHostname,
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
cfg.Analysis.RspamdAPIURL,
)
return &EmailAnalyzer{
@ -57,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
Report *api.Report
Report *model.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
@ -111,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
}
// AnalyzeDomain performs DNS analysis for a domain and returns the results
func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
// Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
@ -121,22 +123,28 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
return dnsResults, score, grade
}
// CheckBlacklistIP checks a single IP address against DNS blacklists
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) {
// Check the IP against all configured RBLs
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
if err != nil {
return nil, 0, 0, "", err
return nil, nil, 0, 0, "", err
}
// Calculate score using the existing function
// Create a minimal RBLResults structure for scoring
results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{ip: checks},
Checks: map[string][]model.BlacklistCheck{ip: checks},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
score, grade := a.analyzer.generator.rblChecker.CalculateScore(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

@ -24,23 +24,25 @@ package analyzer
import (
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
// AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct{}
type AuthenticationAnalyzer struct {
receiverHostname string
}
// NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{}
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &api.AuthenticationResults{}
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
results := &model.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults()
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
@ -63,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
// parseAuthenticationResultsHeader parses an Authentication-Results header
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
@ -89,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
dkimList := []api.AuthResult{*dkimResult}
dkimList := []model.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
@ -138,39 +140,59 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.XAlignedFrom = a.parseXAlignedFromResult(part)
}
}
// Parse x-ptr
if strings.HasPrefix(part, "x-ptr=") {
if results.XPtr == nil {
results.XPtr = a.parseXPtrResult(part)
}
}
// Parse x-tls
if strings.HasPrefix(part, "x-tls=") {
if results.XTls == nil {
results.XTls = a.parseXTLSResult(part)
}
}
}
}
// CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) {
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
if results == nil {
return 0, ""
}
score := 0
// IPRev (15 points)
score += 15 * a.calculateIPRevScore(results) / 100
// Core authentication (90 points total)
// SPF (30 points)
score += 30 * a.calculateSPFScore(results) / 100
// SPF (25 points)
score += 25 * a.calculateSPFScore(results) / 100
// DKIM (30 points)
score += 30 * a.calculateDKIMScore(results) / 100
// DKIM (23 points)
score += 23 * a.calculateDKIMScore(results) / 100
// X-Google-DKIM (optional) - penalty if failed
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// X-Aligned-From
score += 2 * a.calculateXAlignedFromScore(results) / 100
// DMARC (25 points)
score += 25 * a.calculateDMARCScore(results) / 100
// DMARC (30 points)
score += 30 * a.calculateDMARCScore(results) / 100
// BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100
// Penalty-only: IPRev (up to -7 points on failure)
if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
score += 7 * (iprevScore - 100) / 100
}
// Penalty-only: X-Google-DKIM (up to -12 points on failure)
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// Penalty-only: X-Aligned-From (up to -5 points on failure)
score += 5 * a.calculateXAlignedFromScore(results) / 100
// Penalty-only: X-TLS / transport encryption (-10 points when not encrypted)
score += 10 * a.calculateXTLSScore(results) / 100
// Ensure score doesn't exceed 100
if score > 100 {
score = 100

View file

@ -27,7 +27,8 @@ import (
"slices"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// textprotoCanonical converts a header name to canonical form
@ -52,24 +53,24 @@ func pluralize(count int) string {
// parseARCResult parses ARC result from Authentication-Results
// Example: arc=pass
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
result := &api.ARCResult{}
func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult {
result := &model.ARCResult{}
// Extract result (pass, fail, none)
re := regexp.MustCompile(`arc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.ARCResultResult(resultStr)
result.Result = model.ARCResultResult(resultStr)
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc="))
return result
}
// parseARCHeaders parses ARC headers from email message
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult {
// Get all ARC-related headers
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
@ -80,8 +81,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
return nil
}
result := &api.ARCResult{
Result: api.ARCResultResultNone,
result := &model.ARCResult{
Result: model.ARCResultResultNone,
}
// Count the ARC chain length (number of sets)
@ -94,15 +95,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
// Determine overall result
if chainLength == 0 {
result.Result = api.ARCResultResultNone
result.Result = model.ARCResultResultNone
details := "No ARC chain present"
result.Details = &details
} else if !chainValid {
result.Result = api.ARCResultResultFail
result.Result = model.ARCResultResultFail
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
result.Details = &details
} else {
result.Result = api.ARCResultResultPass
result.Result = model.ARCResultResultPass
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
result.Details = &details
}
@ -111,7 +112,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
}
// enhanceARCResult enhances an existing ARC result with chain information
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) {
if arcResult == nil {
return
}

View file

@ -24,33 +24,33 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.ARCResultResult
expectedResult model.ARCResultResult
}{
{
name: "ARC pass",
part: "arc=pass",
expectedResult: api.ARCResultResultPass,
expectedResult: model.ARCResultResultPass,
},
{
name: "ARC fail",
part: "arc=fail",
expectedResult: api.ARCResultResultFail,
expectedResult: model.ARCResultResultFail,
},
{
name: "ARC none",
part: "arc=none",
expectedResult: api.ARCResultResultNone,
expectedResult: model.ARCResultResultNone,
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
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 {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,19 +25,20 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseBIMIResult parses BIMI result from Authentication-Results
// Example: bimi=pass header.d=example.com header.selector=default
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result := &api.AuthResult{}
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`bimi=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@ -54,17 +55,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result.Selector = &selector
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi="))
return result
}
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) {
if results.Bimi != nil {
switch results.Bimi.Result {
case api.AuthResultResultPass:
case model.AuthResultResultPass:
return 100
case api.AuthResultResultDeclined:
case model.AuthResultResultDeclined:
return 59
default: // fail
return 0

View file

@ -24,47 +24,47 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseBIMIResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "BIMI pass with domain and selector",
part: "bimi=pass header.d=example.com header.selector=default",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI fail",
part: "bimi=fail header.d=example.com header.selector=default",
expectedResult: api.AuthResultResultFail,
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI with short form (d= and selector=)",
part: "bimi=pass d=example.com selector=v1",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "v1",
},
{
name: "BIMI none",
part: "bimi=none header.d=example.com",
expectedResult: api.AuthResultResultNone,
expectedResult: model.AuthResultResultNone,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,19 +25,20 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@ -54,18 +55,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result.Selector = &selector
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) {
// Expect at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 {
hasPass := false
hasNonPass := false
for _, dkim := range *results.Dkim {
if dkim.Result == api.AuthResultResultPass {
if dkim.Result == model.AuthResultResultPass {
hasPass = true
} else {
hasNonPass = true

View file

@ -24,41 +24,41 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "DKIM pass with domain and selector",
part: "dkim=pass header.d=example.com header.s=default",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "DKIM fail",
part: "dkim=fail header.d=example.com header.s=selector1",
expectedResult: api.AuthResultResultFail,
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "selector1",
},
{
name: "DKIM with short form (d= and s=)",
part: "dkim=pass d=example.com s=default",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,19 +25,20 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseDMARCResult parses DMARC result from Authentication-Results
// Example: dmarc=pass action=none header.from=example.com
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result := &api.AuthResult{}
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dmarc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.from)
@ -47,17 +48,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result.Domain = &domain
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc="))
return result
}
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) {
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
case model.AuthResultResultPass:
return 100
case api.AuthResultResultNone:
case model.AuthResultResultNone:
return 33
default: // fail
return 0

View file

@ -24,31 +24,31 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseDMARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDomain string
}{
{
name: "DMARC pass",
part: "dmarc=pass action=none header.from=example.com",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "DMARC fail",
part: "dmarc=fail action=quarantine header.from=example.com",
expectedResult: api.AuthResultResultFail,
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,19 +25,20 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
result := &api.IPRevResult{}
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult {
result := &model.IPRevResult{}
// Extract result (pass, fail, temperror, permerror, none)
re := regexp.MustCompile(`iprev=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.IPRevResultResult(resultStr)
result.Result = model.IPRevResultResult(resultStr)
}
// Extract IP address (smtp.remote-ip or remote-ip)
@ -54,20 +55,20 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult
result.Hostname = &hostname
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev="))
return result
}
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) {
if results.Iprev != nil {
switch results.Iprev.Result {
case api.Pass:
case model.Pass:
return 100
default: // fail, temperror, permerror
return 0
}
}
return 0
return 100
}

View file

@ -24,76 +24,77 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseIPRevResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.IPRevResultResult
expectedResult model.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass with IP and hostname",
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("195.110.101.58"),
expectedHostname: api.PtrTo("authsmtp74.register.it"),
expectedResult: model.Pass,
expectedIP: utils.PtrTo("195.110.101.58"),
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev pass without smtp prefix",
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("mail.example.com"),
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
expectedResult: api.Fail,
expectedIP: api.PtrTo("198.51.100.42"),
expectedHostname: api.PtrTo("unknown.host.com"),
expectedResult: model.Fail,
expectedIP: utils.PtrTo("198.51.100.42"),
expectedHostname: utils.PtrTo("unknown.host.com"),
},
{
name: "IPRev temperror",
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
expectedResult: api.Temperror,
expectedIP: api.PtrTo("203.0.113.1"),
expectedResult: model.Temperror,
expectedIP: utils.PtrTo("203.0.113.1"),
expectedHostname: nil,
},
{
name: "IPRev permerror",
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
expectedResult: api.Permerror,
expectedIP: api.PtrTo("192.0.2.100"),
expectedResult: model.Permerror,
expectedIP: utils.PtrTo("192.0.2.100"),
expectedHostname: nil,
},
{
name: "IPRev with IPv6",
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("2001:db8::1"),
expectedHostname: api.PtrTo("ipv6.example.com"),
expectedResult: model.Pass,
expectedIP: utils.PtrTo("2001:db8::1"),
expectedHostname: utils.PtrTo("ipv6.example.com"),
},
{
name: "IPRev with subdomain hostname",
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.50"),
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.50"),
expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
},
{
name: "IPRev pass without parentheses",
part: "iprev=pass smtp.remote-ip=192.0.2.200",
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.200"),
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.200"),
expectedHostname: nil,
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -142,29 +143,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
tests := []struct {
name string
header string
expectedIPRevResult *api.IPRevResultResult
expectedIPRevResult *model.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass in Authentication-Results",
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("195.110.101.58"),
expectedHostname: api.PtrTo("authsmtp74.register.it"),
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("195.110.101.58"),
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev with other authentication methods",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("mail.example.com"),
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
expectedIPRevResult: api.PtrTo(api.Fail),
expectedIP: api.PtrTo("198.51.100.42"),
expectedIPRevResult: utils.PtrTo(model.Fail),
expectedIP: utils.PtrTo("198.51.100.42"),
expectedHostname: nil,
},
{
@ -175,17 +176,17 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
{
name: "Multiple IPRev results - only first is parsed",
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("first.com"),
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("first.com"),
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check IPRev

View file

@ -25,19 +25,20 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseSPFResult parses SPF result from Authentication-Results
// Example: spf=pass smtp.mailfrom=sender@example.com
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
result := &api.AuthResult{}
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`spf=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain
@ -51,25 +52,35 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
}
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf="))
return result
}
// parseLegacySPF attempts to parse SPF from Received-SPF header
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
return nil
}
result := &api.AuthResult{}
// 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 := &model.AuthResult{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
result.Details = &receivedSPF
@ -87,14 +98,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
return result
}
func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) {
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
case model.AuthResultResultPass:
return 100
case api.AuthResultResultNeutral, api.AuthResultResultNone:
case model.AuthResultResultNeutral, model.AuthResultResultNone:
return 50
case api.AuthResultResultSoftfail:
case model.AuthResultResultSoftfail:
return 17
default: // fail, temperror, permerror
return 0

View file

@ -24,43 +24,44 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseSPFResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDomain string
}{
{
name: "SPF pass with domain",
part: "spf=pass smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "SPF fail",
part: "spf=fail smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultFail,
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "SPF neutral",
part: "spf=neutral smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultNeutral,
expectedResult: model.AuthResultResultNeutral,
expectedDomain: "example.com",
},
{
name: "SPF softfail",
part: "spf=softfail smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultSoftfail,
expectedResult: model.AuthResultResultSoftfail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -84,7 +85,7 @@ func TestParseLegacySPF(t *testing.T) {
tests := []struct {
name string
receivedSPF string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDomain *string
expectNil bool
}{
@ -97,8 +98,8 @@ func TestParseLegacySPF(t *testing.T) {
envelope-from="user@example.com";
helo=smtp.example.com;
client-ip=192.0.2.10`,
expectedResult: api.AuthResultResultPass,
expectedDomain: api.PtrTo("example.com"),
expectedResult: model.AuthResultResultPass,
expectedDomain: utils.PtrTo("example.com"),
},
{
name: "SPF fail with sender",
@ -109,43 +110,43 @@ func TestParseLegacySPF(t *testing.T) {
sender="sender@test.com";
helo=smtp.test.com;
client-ip=192.0.2.20`,
expectedResult: api.AuthResultResultFail,
expectedDomain: api.PtrTo("test.com"),
expectedResult: model.AuthResultResultFail,
expectedDomain: utils.PtrTo("test.com"),
},
{
name: "SPF softfail",
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
expectedResult: api.AuthResultResultSoftfail,
expectedDomain: api.PtrTo("example.org"),
expectedResult: model.AuthResultResultSoftfail,
expectedDomain: utils.PtrTo("example.org"),
},
{
name: "SPF neutral",
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
expectedResult: api.AuthResultResultNeutral,
expectedDomain: api.PtrTo("domain.net"),
expectedResult: model.AuthResultResultNeutral,
expectedDomain: utils.PtrTo("domain.net"),
},
{
name: "SPF none",
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
expectedResult: api.AuthResultResultNone,
expectedDomain: api.PtrTo("company.io"),
expectedResult: model.AuthResultResultNone,
expectedDomain: utils.PtrTo("company.io"),
},
{
name: "SPF temperror",
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
expectedResult: api.AuthResultResultTemperror,
expectedDomain: api.PtrTo("shop.example"),
expectedResult: model.AuthResultResultTemperror,
expectedDomain: utils.PtrTo("shop.example"),
},
{
name: "SPF permerror",
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
expectedResult: api.AuthResultResultPermerror,
expectedDomain: api.PtrTo("invalid.test"),
expectedResult: model.AuthResultResultPermerror,
expectedDomain: utils.PtrTo("invalid.test"),
},
{
name: "SPF pass without domain extraction",
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: nil,
},
{
@ -156,12 +157,12 @@ func TestParseLegacySPF(t *testing.T) {
{
name: "SPF with unquoted envelope-from",
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
expectedResult: api.AuthResultResultPass,
expectedDomain: api.PtrTo("mail.example.net"),
expectedResult: model.AuthResultResultPass,
expectedDomain: utils.PtrTo("mail.example.net"),
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -24,83 +24,84 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
results *api.AuthenticationResults
results *model.AuthenticationResults
expectedScore int
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
Dkim: &[]model.AuthResult{
{Result: model.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
Dmarc: &model.AuthResult{
Result: model.AuthResultResultPass,
},
},
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30
},
{
name: "SPF and DKIM only",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
Dkim: &[]model.AuthResult{
{Result: model.AuthResultResultPass},
},
},
expectedScore: 48, // SPF=25 + DKIM=23
expectedScore: 60, // SPF=30 + DKIM=30
},
{
name: "SPF fail, DKIM pass",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultFail,
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultFail,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
Dkim: &[]model.AuthResult{
{Result: model.AuthResultResultPass},
},
},
expectedScore: 23, // SPF=0 + DKIM=23
expectedScore: 30, // SPF=0 + DKIM=30
},
{
name: "SPF softfail",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultSoftfail,
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultSoftfail,
},
},
expectedScore: 4,
expectedScore: 5, // 30 * 17 / 100 = 5
},
{
name: "No authentication",
results: &api.AuthenticationResults{},
results: &model.AuthenticationResults{},
expectedScore: 0,
},
{
name: "BIMI adds to score",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultPass,
},
Bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
Bimi: &model.AuthResult{
Result: model.AuthResultResultPass,
},
},
expectedScore: 35, // SPF (25) + BIMI (10)
expectedScore: 40, // SPF (30) + BIMI (10)
},
}
scorer := NewAuthenticationAnalyzer()
scorer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -117,30 +118,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
tests := []struct {
name string
header string
expectedSPFResult *api.AuthResultResult
expectedSPFResult *model.AuthResultResult
expectedSPFDomain *string
expectedDKIMCount int
expectedDKIMResult *api.AuthResultResult
expectedDMARCResult *api.AuthResultResult
expectedDKIMResult *model.AuthResultResult
expectedDMARCResult *model.AuthResultResult
expectedDMARCDomain *string
expectedBIMIResult *api.AuthResultResult
expectedARCResult *api.ARCResultResult
expectedBIMIResult *model.AuthResultResult
expectedARCResult *model.ARCResultResult
}{
{
name: "Complete authentication results",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCDomain: api.PtrTo("example.com"),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCDomain: utils.PtrTo("example.com"),
},
{
name: "SPF only",
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("domain.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("domain.com"),
expectedDKIMCount: 0,
expectedDMARCResult: nil,
},
@ -149,68 +150,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
},
{
name: "Multiple DKIM signatures",
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
expectedSPFResult: nil,
expectedDKIMCount: 2,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: nil,
},
{
name: "SPF fail with DKIM pass",
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
expectedSPFResult: api.PtrTo(api.AuthResultResultFail),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultFail),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: nil,
},
{
name: "SPF softfail",
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
expectedDMARCResult: nil,
},
{
name: "DMARC fail",
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
expectedDMARCDomain: api.PtrTo("example.com"),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail),
expectedDMARCDomain: utils.PtrTo("example.com"),
},
{
name: "BIMI pass",
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
},
{
name: "ARC pass",
header: "mail.example.com; arc=pass",
expectedSPFResult: nil,
expectedDKIMCount: 0,
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
},
{
name: "All authentication methods",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCDomain: api.PtrTo("example.com"),
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCDomain: utils.PtrTo("example.com"),
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
},
{
name: "Empty header (authserv-id only)",
@ -221,8 +222,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
{
name: "Empty parts with semicolons",
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
},
{
@ -230,28 +231,28 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
header: "mail.example.com; dkim=pass d=example.com s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
},
{
name: "SPF neutral",
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral),
expectedSPFDomain: api.PtrTo("example.com"),
expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
},
{
name: "SPF none",
header: "mail.example.com; spf=none",
expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
expectedSPFResult: utils.PtrTo(model.AuthResultResultNone),
expectedDKIMCount: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check SPF
@ -353,17 +354,17 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
// 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) {
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Spf == nil {
t.Fatal("Expected SPF result, got nil")
}
if results.Spf.Result != api.AuthResultResultPass {
if results.Spf.Result != model.AuthResultResultPass {
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
}
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
@ -373,13 +374,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dmarc == nil {
t.Fatal("Expected DMARC result, got nil")
}
if results.Dmarc.Result != api.AuthResultResultPass {
if results.Dmarc.Result != model.AuthResultResultPass {
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
}
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
@ -389,26 +390,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; arc=pass; arc=fail"
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Arc == nil {
t.Fatal("Expected ARC result, got nil")
}
if results.Arc.Result != api.ARCResultResultPass {
if results.Arc.Result != model.ARCResultResultPass {
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
}
})
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Bimi == nil {
t.Fatal("Expected BIMI result, got nil")
}
if results.Bimi.Result != api.AuthResultResultPass {
if results.Bimi.Result != model.AuthResultResultPass {
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
}
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
@ -419,7 +420,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
// DKIM is special - multiple signatures should all be collected
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
results := &api.AuthenticationResults{}
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dkim == nil {
@ -428,10 +429,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
if len(*results.Dkim) != 2 {
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
}
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
if (*results.Dkim)[0].Result != model.AuthResultResultPass {
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
}
if (*results.Dkim)[1].Result != api.AuthResultResultFail {
if (*results.Dkim)[1].Result != model.AuthResultResultFail {
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
}
})

View file

@ -25,36 +25,37 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
// Example: x-aligned-from=pass (Address match)
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
result := &api.AuthResult{}
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
// Extract details (everything after the result)
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
return result
}
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) {
if results.XAlignedFrom != nil {
switch results.XAlignedFrom.Result {
case api.AuthResultResultPass:
// pass: positive contribution
return 100
case api.AuthResultResultFail:
// fail: negative contribution
case model.AuthResultResultPass:
// pass: no impact
return 0
case model.AuthResultResultFail:
// fail: negative contribution
return -100
default:
// neutral, none, etc.: no impact
return 0

View file

@ -24,49 +24,49 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDetail string
}{
{
name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDetail: "pass (Address match)",
},
{
name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)",
expectedResult: api.AuthResultResultFail,
expectedResult: model.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)",
},
{
name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDetail: "pass",
},
{
name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)",
expectedResult: api.AuthResultResultNeutral,
expectedResult: model.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)",
},
{
name: "x-aligned-from none",
part: "x-aligned-from=none",
expectedResult: api.AuthResultResultNone,
expectedResult: model.AuthResultResultNone,
expectedDetail: "none",
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) {
func TestCalculateXAlignedFromScore(t *testing.T) {
tests := []struct {
name string
result *api.AuthResult
result *model.AuthResult
expectedScore int
}{
{
name: "pass result gives positive score",
result: &api.AuthResult{
Result: api.AuthResultResultPass,
},
expectedScore: 100,
},
{
name: "fail result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultFail,
name: "pass result gives no penalty",
result: &model.AuthResult{
Result: model.AuthResultResultPass,
},
expectedScore: 0,
},
{
name: "fail result gives full penalty",
result: &model.AuthResult{
Result: model.AuthResultResultFail,
},
expectedScore: -100,
},
{
name: "neutral result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultNeutral,
result: &model.AuthResult{
Result: model.AuthResultResultNeutral,
},
expectedScore: 0,
},
{
name: "none result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultNone,
result: &model.AuthResult{
Result: model.AuthResultResultNone,
},
expectedScore: 0,
},
@ -126,11 +126,11 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.AuthenticationResults{
results := &model.AuthenticationResults{
XAlignedFrom: tt.result,
}

View file

@ -25,19 +25,20 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthRe
result.Selector = &selector
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
if results.XGoogleDkim != nil {
switch results.XGoogleDkim.Result {
case api.AuthResultResultPass:
case model.AuthResultResultPass:
// pass: don't alter the score
default: // fail
return -100

View file

@ -24,43 +24,43 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseXGoogleDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "x-google-dkim pass with domain",
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "1e100.net",
},
{
name: "x-google-dkim pass with short form",
part: "x-google-dkim=pass d=gmail.com",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
expectedDomain: "gmail.com",
},
{
name: "x-google-dkim fail",
part: "x-google-dkim=fail header.d=example.com",
expectedResult: api.AuthResultResultFail,
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "x-google-dkim with minimal info",
part: "x-google-dkim=pass",
expectedResult: api.AuthResultResultPass,
expectedResult: model.AuthResultResultPass,
},
}
analyzer := NewAuthenticationAnalyzer()
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -0,0 +1,61 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseXPtrResult parses the x-ptr result from Authentication-Results.
// Example: x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com
func (a *AuthenticationAnalyzer) parseXPtrResult(part string) *model.XPtrResult {
result := &model.XPtrResult{}
// Extract result (pass, fail, none, temperror, permerror)
re := regexp.MustCompile(`x-ptr=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.XPtrResultResult(resultStr)
}
// Extract announced HELO hostname (smtp.helo)
heloRe := regexp.MustCompile(`smtp\.helo=([^\s;()]+)`)
if matches := heloRe.FindStringSubmatch(part); len(matches) > 1 {
helo := matches[1]
result.Helo = &helo
}
// Extract reverse DNS hostname (policy.ptr)
ptrRe := regexp.MustCompile(`policy\.ptr=([^\s;()]+)`)
if matches := ptrRe.FindStringSubmatch(part); len(matches) > 1 {
ptr := matches[1]
result.Ptr = &ptr
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-ptr="))
return result
}

View file

@ -0,0 +1,81 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseXPtrResult(t *testing.T) {
a := NewAuthenticationAnalyzer("receiver.com")
tests := []struct {
name string
part string
expectedResult model.XPtrResultResult
expectedHelo *string
expectedPtr *string
}{
{
name: "x-ptr fail with helo and ptr",
part: "x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com",
expectedResult: model.XPtrResultResultFail,
expectedHelo: utils.PtrTo("relay.example.org"),
expectedPtr: utils.PtrTo("mail.example.com"),
},
{
name: "x-ptr pass",
part: "x-ptr=pass smtp.helo=mail.example.com policy.ptr=mail.example.com",
expectedResult: model.XPtrResultResultPass,
expectedHelo: utils.PtrTo("mail.example.com"),
expectedPtr: utils.PtrTo("mail.example.com"),
},
{
name: "x-ptr none without ptr",
part: "x-ptr=none smtp.helo=relay.example.org",
expectedResult: model.XPtrResultResultNone,
expectedHelo: utils.PtrTo("relay.example.org"),
expectedPtr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := a.parseXPtrResult(tt.part)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Result != tt.expectedResult {
t.Errorf("Result = %q, want %q", result.Result, tt.expectedResult)
}
if !equalStrPtr(result.Helo, tt.expectedHelo) {
t.Errorf("Helo = %v, want %v", result.Helo, tt.expectedHelo)
}
if !equalStrPtr(result.Ptr, tt.expectedPtr) {
t.Errorf("Ptr = %v, want %v", result.Ptr, tt.expectedPtr)
}
})
}
}

View file

@ -0,0 +1,154 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseXTLSResult parses the x-tls result from Authentication-Results.
// Example: x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256
func (a *AuthenticationAnalyzer) parseXTLSResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, none, ...)
re := regexp.MustCompile(`x-tls=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
result.Result = model.AuthResultResult(strings.ToLower(matches[1]))
}
result.Details = utils.PtrTo(formatTLSDetails(
submatch(part, `smtp\.version=([^\s;()]+)`),
submatch(part, `smtp\.cipher=([^\s;()]+)`),
submatch(part, `smtp\.bits=(\d+)`),
))
return result
}
// calculateXTLSScore returns a penalty for a negative transport-TLS result.
// pass (or absent) does not alter the score; any other result is penalized.
func (a *AuthenticationAnalyzer) calculateXTLSScore(results *model.AuthenticationResults) (score int) {
if results.XTls != nil {
switch results.XTls.Result {
case model.AuthResultResultPass:
// pass: don't alter the score
default:
return -100
}
}
return 0
}
// ReconcileXTLS fills in the x-tls result from the inbound connection's parsed TLS
// information when no x-tls Authentication-Results header was present. The inbound
// connection is the most recent hop (index 0) of the received chain.
func (a *AuthenticationAnalyzer) ReconcileXTLS(results *model.AuthenticationResults, chain *[]model.ReceivedHop) {
if results == nil || results.XTls != nil {
return
}
if chain == nil || len(*chain) == 0 {
return
}
inbound := (*chain)[0]
switch {
case inbound.Tls != nil:
// Full TLS parenthetical present (smtpd_tls_received_header = yes).
var version, cipher, bits string
if inbound.Tls.Version != nil {
version = *inbound.Tls.Version
}
if inbound.Tls.Cipher != nil {
cipher = *inbound.Tls.Cipher
}
if inbound.Tls.Bits != nil {
bits = fmt.Sprintf("%d", *inbound.Tls.Bits)
}
results.XTls = &model.AuthResult{
Result: model.AuthResultResultPass,
Details: utils.PtrTo(formatTLSDetails(version, cipher, bits)),
}
case protocolIndicatesTLS(inbound.With):
// No TLS parenthetical (smtpd_tls_received_header may be disabled), but the
// transport keyword (ESMTPS, ESMTPSA, ...) tells us the session was encrypted.
// We just don't have the cipher details.
results.XTls = &model.AuthResult{
Result: model.AuthResultResultPass,
Details: utils.PtrTo(fmt.Sprintf("Encrypted connection (%s); cipher details unavailable", *inbound.With)),
}
case inbound.With != nil:
// A plaintext transport keyword (SMTP, ESMTP, ESMTPA, ...) is positive
// evidence the inbound connection was not encrypted.
results.XTls = &model.AuthResult{
Result: model.AuthResultResultNone,
Details: utils.PtrTo(fmt.Sprintf("Inbound connection was not encrypted (%s)", *inbound.With)),
}
default:
// Neither TLS details nor a transport keyword: we cannot tell whether the
// connection was encrypted. Leave x-tls unset rather than wrongly penalize.
}
}
// protocolIndicatesTLS reports whether an SMTP "with" transport keyword denotes a
// TLS-encrypted session. Per RFC 3848 the keyword gains a trailing "S" when STARTTLS
// (or implicit TLS) was negotiated: ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA, UTF8SMTPS...
// The plaintext variants end in "P" (SMTP, ESMTP, LMTP) or "A" (ESMTPA, LMTPA).
func protocolIndicatesTLS(with *string) bool {
if with == nil {
return false
}
p := strings.ToUpper(strings.TrimSpace(*with))
return strings.HasSuffix(p, "S") || strings.HasSuffix(p, "SA")
}
// submatch returns the first capture group of pattern in s, or "".
func submatch(s, pattern string) string {
if matches := regexp.MustCompile(pattern).FindStringSubmatch(s); len(matches) > 1 {
return matches[1]
}
return ""
}
// formatTLSDetails builds a human-readable summary of the TLS parameters.
func formatTLSDetails(version, cipher, bits string) string {
var parts []string
if version != "" {
parts = append(parts, version)
}
if cipher != "" {
parts = append(parts, "cipher "+cipher)
}
if bits != "" {
parts = append(parts, bits+" bits")
}
return strings.Join(parts, ", ")
}

View file

@ -0,0 +1,165 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseXTLSResult(t *testing.T) {
analyzer := NewAuthenticationAnalyzer("")
result := analyzer.parseXTLSResult("x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256")
if result.Result != model.AuthResultResultPass {
t.Errorf("Result = %v, want pass", result.Result)
}
if result.Details == nil {
t.Fatal("Details should not be nil")
}
for _, want := range []string{"TLSv1.3", "TLS_AES_256_GCM_SHA384", "256 bits"} {
if !strings.Contains(*result.Details, want) {
t.Errorf("Details %q should contain %q", *result.Details, want)
}
}
}
func TestCalculateXTLSScore(t *testing.T) {
analyzer := NewAuthenticationAnalyzer("")
tests := []struct {
name string
xtls *model.AuthResult
score int
}{
{"nil", nil, 0},
{"pass", &model.AuthResult{Result: model.AuthResultResultPass}, 0},
{"none", &model.AuthResult{Result: model.AuthResultResultNone}, -100},
{"fail", &model.AuthResult{Result: model.AuthResultResultFail}, -100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{XTls: tt.xtls}
if got := analyzer.calculateXTLSScore(results); got != tt.score {
t.Errorf("calculateXTLSScore = %d, want %d", got, tt.score)
}
})
}
}
func TestReconcileXTLS(t *testing.T) {
analyzer := NewAuthenticationAnalyzer("")
t.Run("keeps existing x-tls header result", func(t *testing.T) {
existing := &model.AuthResult{Result: model.AuthResultResultFail}
results := &model.AuthenticationResults{XTls: existing}
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{Version: utils.PtrTo("TLSv1.3")}}}
analyzer.ReconcileXTLS(results, chain)
if results.XTls != existing {
t.Error("existing XTls should be preserved")
}
})
t.Run("synthesizes pass from encrypted inbound hop", func(t *testing.T) {
results := &model.AuthenticationResults{}
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{
Version: utils.PtrTo("TLSv1.3"),
Cipher: utils.PtrTo("TLS_AES_256_GCM_SHA384"),
Bits: utils.PtrTo(256),
}}}
analyzer.ReconcileXTLS(results, chain)
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
}
if results.XTls.Details == nil || !strings.Contains(*results.XTls.Details, "TLSv1.3") {
t.Errorf("details should mention TLS version, got %v", results.XTls.Details)
}
})
t.Run("synthesizes pass from ESMTPS protocol without TLS parenthetical", func(t *testing.T) {
// smtpd_tls_received_header disabled: no TLS details, but ESMTPS proves encryption.
results := &model.AuthenticationResults{}
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTPS")}}
analyzer.ReconcileXTLS(results, chain)
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
}
})
t.Run("synthesizes none from plaintext ESMTP protocol", func(t *testing.T) {
results := &model.AuthenticationResults{}
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTP")}}
analyzer.ReconcileXTLS(results, chain)
if results.XTls == nil || results.XTls.Result != model.AuthResultResultNone {
t.Fatalf("expected synthesized none, got %+v", results.XTls)
}
})
t.Run("leaves nil when neither TLS info nor protocol is known", func(t *testing.T) {
results := &model.AuthenticationResults{}
chain := &[]model.ReceivedHop{{}}
analyzer.ReconcileXTLS(results, chain)
if results.XTls != nil {
t.Errorf("expected nil XTls when undetermined, got %+v", results.XTls)
}
})
t.Run("leaves nil with empty chain", func(t *testing.T) {
results := &model.AuthenticationResults{}
analyzer.ReconcileXTLS(results, &[]model.ReceivedHop{})
if results.XTls != nil {
t.Errorf("expected nil XTls, got %+v", results.XTls)
}
})
}
func TestProtocolIndicatesTLS(t *testing.T) {
tests := []struct {
with string
want bool
}{
{"ESMTPS", true},
{"ESMTPSA", true},
{"SMTPS", true},
{"LMTPS", true},
{"LMTPSA", true},
{"SMTP", false},
{"ESMTP", false},
{"ESMTPA", false},
{"LMTP", false},
}
for _, tt := range tests {
t.Run(tt.with, func(t *testing.T) {
if got := protocolIndicatesTLS(utils.PtrTo(tt.with)); got != tt.want {
t.Errorf("protocolIndicatesTLS(%q) = %v, want %v", tt.with, got, tt.want)
}
})
}
if protocolIndicatesTLS(nil) {
t.Error("protocolIndicatesTLS(nil) should be false")
}
}

View file

@ -32,7 +32,8 @@ import (
"time"
"unicode"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"golang.org/x/net/html"
)
@ -82,6 +83,27 @@ type ContentResults struct {
HarmfullIssues []string
}
// templatePlaceholderRegex matches unreplaced templating tokens that remain when a
// merge field was not substituted before sending. It covers the common syntaxes:
// - single/double curly braces: {unsubscribe}, {{unsubscribe_url}}
// - dollar braces: ${unsubscribe}
// - Mailchimp merge tags: *|UNSUB|*
// - percent tags: %unsubscribe%, %%unsubscribe%%
// - square bracket tags: [unsubscribe]
// - URL-encoded curly braces: %7Bunsubscribe%7D
//
// The percent-tag alternative requires the body to contain at least one non-hex
// character ([g-z_.-]). Percent-encoded octets (e.g. "%C3%A9", "%E2%80%A6") only
// ever place hex digits between "%" signs, so this distinguishes real merge tags
// from ordinary percent-encoding and avoids flagging internationalized URLs.
var templatePlaceholderRegex = regexp.MustCompile(`(?i)\{\{?[^{}]*\}?\}|\$\{[^}]*\}|\*\|[^|]*\|\*|%{1,2}[\w.\-]*[g-z_.\-][\w.\-]*%{1,2}|\[[a-z][\w.\-]*\]|%7b[^%]*%7d`)
// isTemplatePlaceholderURL reports whether a URL still contains an unreplaced
// templating placeholder, meaning the merge field was never substituted.
func isTemplatePlaceholderURL(urlStr string) bool {
return templatePlaceholderRegex.MatchString(urlStr)
}
// HasPlaintext returns true if the email has plain text content
func (r *ContentResults) HasPlaintext() bool {
return r.TextContent != ""
@ -95,6 +117,7 @@ type LinkCheck struct {
Error string
IsSafe bool
Warning string
IsTemplate bool // URL still contains an unreplaced templating placeholder (e.g. "{unsubscribe}")
}
// ImageCheck represents an image validation result
@ -341,6 +364,13 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
// isUnsubscribeLink checks if a link is an unsubscribe link
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
// An href with an unreplaced template placeholder (e.g. "{unsubscribe}") is not a
// working link, so it must not count as a valid unsubscribe method even though it
// literally contains the word "unsubscribe".
if isTemplatePlaceholderURL(href) {
return false
}
// First check: does the href match a URL from the List-Unsubscribe header?
if slices.Contains(c.listUnsubscribeURLs, href) {
return true
@ -386,6 +416,15 @@ func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck {
IsSafe: true,
}
// Detect unreplaced templating placeholders (e.g. "{unsubscribe}"). Such a URL
// is not a real link: the merge field was never substituted before sending.
if isTemplatePlaceholderURL(urlStr) {
check.Valid = false
check.IsTemplate = true
check.Error = "URL contains an unreplaced template placeholder (merge field was not substituted before sending)"
return check
}
// Parse URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
@ -500,6 +539,11 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
return false
}
// Replace email addresses with just their domain part to avoid false positives
// e.g. "john.doe@example.com" → "example.com" so local-part dots don't look like domains
emailAddrRegex := regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@([a-z0-9.\-]+\.[a-z]{2,})`)
linkText = emailAddrRegex.ReplaceAllString(linkText, "$1")
// Common generic link texts that shouldn't trigger warnings
genericTexts := []string{
"click here", "read more", "learn more", "download", "subscribe",
@ -728,16 +772,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
}
// GenerateContentAnalysis creates structured content analysis from results
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis {
if results == nil {
return nil
}
analysis := &api.ContentAnalysis{
HasHtml: api.PtrTo(results.HTMLContent != ""),
HasPlaintext: api.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
analysis := &model.ContentAnalysis{
HasHtml: utils.PtrTo(results.HTMLContent != ""),
HasPlaintext: utils.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe),
UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{},
}
// Calculate text-to-image ratio (inverse of image-to-text)
@ -750,16 +794,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
}
// Build HTML issues
htmlIssues := []api.ContentIssue{}
htmlIssues := []model.ContentIssue{}
// Add HTML parsing errors
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
for _, errMsg := range results.HTMLErrors {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.BrokenHtml,
Severity: api.ContentIssueSeverityHigh,
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.BrokenHtml,
Severity: model.ContentIssueSeverityHigh,
Message: errMsg,
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
})
}
}
@ -773,53 +817,68 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
}
}
if missingAltCount > 0 {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.MissingAlt,
Severity: api.ContentIssueSeverityMedium,
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.MissingAlt,
Severity: model.ContentIssueSeverityMedium,
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
})
}
}
// Add excessive images issue
if results.ImageTextRatio > 10.0 {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.ExcessiveImages,
Severity: api.ContentIssueSeverityMedium,
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.ExcessiveImages,
Severity: model.ContentIssueSeverityMedium,
Message: "Email is excessively image-heavy",
Advice: api.PtrTo("Reduce the number of images relative to text content"),
Advice: utils.PtrTo("Reduce the number of images relative to text content"),
})
}
// Add unreplaced template placeholder issues
for _, link := range results.Links {
if !link.IsTemplate {
continue
}
location := link.URL
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.UnreplacedTemplate,
Severity: model.ContentIssueSeverityHigh,
Message: fmt.Sprintf("Link contains an unreplaced template placeholder: %s", link.URL),
Location: &location,
Advice: utils.PtrTo("Ensure all merge fields and template placeholders are substituted before sending"),
})
}
// Add suspicious URL issues
for _, suspURL := range results.SuspiciousURLs {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.SuspiciousLink,
Severity: api.ContentIssueSeverityHigh,
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.SuspiciousLink,
Severity: model.ContentIssueSeverityHigh,
Message: "Suspicious URL detected",
Location: &suspURL,
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
})
}
// Add harmful HTML tag issues
for _, harmfulIssue := range results.HarmfullIssues {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.DangerousHtml,
Severity: api.ContentIssueSeverityCritical,
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.DangerousHtml,
Severity: model.ContentIssueSeverityCritical,
Message: harmfulIssue,
Advice: api.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
Advice: utils.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
})
}
// Add general content issues (like external stylesheets)
for _, contentIssue := range results.ContentIssues {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.BrokenHtml,
Severity: api.ContentIssueSeverityLow,
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.BrokenHtml,
Severity: model.ContentIssueSeverityLow,
Message: contentIssue,
Advice: api.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
Advice: utils.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
})
}
@ -829,31 +888,34 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
// Convert links
if len(results.Links) > 0 {
links := make([]api.LinkCheck, 0, len(results.Links))
links := make([]model.LinkCheck, 0, len(results.Links))
for _, link := range results.Links {
status := api.Valid
if link.Status >= 400 {
status = api.Broken
status := model.Valid
if !link.Valid {
// Link could not be parsed/validated (e.g. unreplaced template placeholder)
status = model.Broken
} else if link.Status >= 400 {
status = model.Broken
} else if !link.IsSafe {
status = api.Suspicious
status = model.Suspicious
} else if link.Warning != "" {
status = api.Timeout
status = model.Timeout
}
apiLink := api.LinkCheck{
apiLink := model.LinkCheck{
Url: link.URL,
Status: status,
}
if link.Status > 0 {
apiLink.HttpCode = api.PtrTo(link.Status)
apiLink.HttpCode = utils.PtrTo(link.Status)
}
// Check if it's a URL shortener
parsedURL, err := url.Parse(link.URL)
if err == nil {
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
apiLink.IsShortened = api.PtrTo(isShortened)
apiLink.IsShortened = utils.PtrTo(isShortened)
}
links = append(links, apiLink)
@ -863,9 +925,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
// Convert images
if len(results.Images) > 0 {
images := make([]api.ImageCheck, 0, len(results.Images))
images := make([]model.ImageCheck, 0, len(results.Images))
for _, img := range results.Images {
apiImg := api.ImageCheck{
apiImg := model.ImageCheck{
HasAlt: img.HasAlt,
}
if img.Src != "" {
@ -875,7 +937,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
apiImg.AltText = &img.AltText
}
// Simple heuristic: tracking pixels are typically 1x1
apiImg.IsTrackingPixel = api.PtrTo(false)
apiImg.IsTrackingPixel = utils.PtrTo(false)
images = append(images, apiImg)
}
@ -884,19 +946,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
// Unsubscribe methods
if results.HasUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Link)
}
for _, url := range c.listUnsubscribeURLs {
if strings.HasPrefix(url, "mailto:") {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Mailto)
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader)
}
}
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
if slices.Contains(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.OneClick)
}
return analysis

View file

@ -24,10 +24,12 @@ package analyzer
import (
"net/mail"
"net/url"
"slices"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
"golang.org/x/net/html"
)
@ -212,6 +214,25 @@ func TestIsUnsubscribeLink(t *testing.T) {
linkText: "Üyeliği sonlandır",
expected: true,
},
// Unreplaced template placeholders must NOT count as unsubscribe methods
{
name: "Curly brace template placeholder",
href: "{unsubscribe}",
linkText: "Unsubscribe",
expected: false,
},
{
name: "Double curly brace template placeholder",
href: "{{unsubscribe_url}}",
linkText: "Unsubscribe",
expected: false,
},
{
name: "Mailchimp merge tag placeholder",
href: "*|UNSUB|*",
linkText: "Unsubscribe here",
expected: false,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
@ -979,3 +1000,90 @@ func TestHasDomainMisalignment(t *testing.T) {
})
}
}
func TestIsTemplatePlaceholderURL(t *testing.T) {
tests := []struct {
name string
url string
expected bool
}{
{name: "Single curly braces", url: "{unsubscribe}", expected: true},
{name: "Double curly braces", url: "{{unsubscribe_url}}", expected: true},
{name: "Dollar braces", url: "${unsubscribe}", expected: true},
{name: "Mailchimp merge tag", url: "*|UNSUB|*", expected: true},
{name: "Percent tag", url: "%unsubscribe%", expected: true},
{name: "Double percent tag", url: "%%unsubscribe%%", expected: true},
{name: "Square bracket tag", url: "[unsubscribe]", expected: true},
{name: "URL-encoded curly braces", url: "https://example.com/u/%7Btoken%7D", expected: true},
{name: "Placeholder embedded in URL", url: "https://example.com/unsub?id={{recipient_id}}", expected: true},
{name: "Normal https URL", url: "https://example.com/unsubscribe?id=123", expected: false},
{name: "Normal URL with percent-encoded space", url: "https://example.com/path%20name", expected: false},
{name: "Percent-encoded accented char (é)", url: "https://example.com/caf%C3%A9/unsubscribe", expected: false},
{name: "Percent-encoded UTF-8 ellipsis", url: "https://example.com/path?q=%E2%80%A6", expected: false},
{name: "Percent-encoded Cyrillic", url: "https://example.com/r?u=%D0%BF%D1%80%D0%B8%D0%B2", expected: false},
{name: "Adjacent percent-encoded octets (all hex)", url: "https://example.com/%aa%bb", expected: false},
{name: "Percent escape then literal hex letters", url: "https://example.com/x%def%20y", expected: false},
{name: "Short percent tag with non-hex letter", url: "%id%", expected: true},
{name: "Mailto URL", url: "mailto:unsubscribe@example.com", expected: false},
{name: "IPv6 URL (square brackets, not a tag)", url: "http://[::1]/unsubscribe", expected: false},
{name: "IPv6 URL with hex-letter host", url: "http://[fe80::1]/unsubscribe", expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTemplatePlaceholderURL(tt.url); got != tt.expected {
t.Errorf("isTemplatePlaceholderURL(%q) = %v, want %v", tt.url, got, tt.expected)
}
})
}
}
func TestValidateLink_TemplatePlaceholderIsInvalid(t *testing.T) {
analyzer := NewContentAnalyzer(5 * time.Second)
check := analyzer.validateLink("{unsubscribe}")
if check.Valid {
t.Errorf("validateLink(%q).Valid = true, want false", "{unsubscribe}")
}
if check.Error == "" {
t.Errorf("validateLink(%q).Error is empty, want a template placeholder error", "{unsubscribe}")
}
}
func TestGenerateContentAnalysis_TemplateLinkNotUnsubscribe(t *testing.T) {
analyzer := NewContentAnalyzer(5 * time.Second)
results := &ContentResults{
HTMLContent: "<html><body><a href=\"{unsubscribe}\">Unsubscribe</a></body></html>",
Links: []LinkCheck{{URL: "{unsubscribe}", Valid: false, IsTemplate: true, IsSafe: true, Error: "template"}},
HasUnsubscribe: false,
}
analysis := analyzer.GenerateContentAnalysis(results)
// The link must be reported as broken, not valid
if analysis.Links == nil || len(*analysis.Links) != 1 {
t.Fatalf("expected 1 link in analysis, got %v", analysis.Links)
}
if (*analysis.Links)[0].Status != model.Broken {
t.Errorf("template link status = %q, want %q", (*analysis.Links)[0].Status, model.Broken)
}
// It must not be counted as an unsubscribe method
if analysis.UnsubscribeMethods != nil && slices.Contains(*analysis.UnsubscribeMethods, model.Link) {
t.Errorf("template link wrongly counted as an unsubscribe method: %v", *analysis.UnsubscribeMethods)
}
// An unreplaced template issue must be reported
foundIssue := false
if analysis.HtmlIssues != nil {
for _, issue := range *analysis.HtmlIssues {
if issue.Type == model.UnreplacedTemplate {
foundIssue = true
}
}
}
if !foundIssue {
t.Errorf("expected an unreplaced_template content issue, got %v", analysis.HtmlIssues)
}
}

View file

@ -24,7 +24,7 @@ package analyzer
import (
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
// DNSAnalyzer analyzes DNS records for email domains
@ -54,16 +54,16 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
}
// 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 *model.HeaderAnalysis) *model.DNSResults {
// Extract domain from From address
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
return &api.DNSResults{
return &model.DNSResults{
Errors: &[]string{"Unable to extract domain from email"},
}
}
fromDomain := *headersResults.DomainAlignment.FromDomain
results := &api.DNSResults{
results := &model.DNSResults{
FromDomain: fromDomain,
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
}
@ -88,6 +88,16 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
if len(forwardRecords) > 0 {
results.PtrForwardRecords = &forwardRecords
}
// Record the announced HELO name and whether it matches the PTR record
if firstHop.From != nil && *firstHop.From != "" {
helo := *firstHop.From
results.HeloHostname = &helo
if len(ptrRecords) > 0 {
match := checkHeloPtrMatch(helo, ptrRecords)
results.HeloPtrMatch = &match
}
}
}
}
@ -100,25 +110,29 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
}
// Verify the sender domains can actually receive replies/bounces (MX, with
// A/AAAA fallback), mirroring the ReturnOK milter check.
results.ReturnOk = &model.ReturnOK{
From: d.checkReturnOKDomain(fromDomain, orgDomainOrEmpty(headersResults.DomainAlignment.FromOrgDomain)),
}
if results.RpDomain != nil && *results.RpDomain != "" {
results.ReturnOk.ReturnPath = d.checkReturnOKDomain(*results.RpDomain, orgDomainOrEmpty(headersResults.DomainAlignment.ReturnPathOrgDomain))
}
// Check SPF records (for Return-Path domain - this is the envelope sender)
// SPF validates the MAIL FROM command, which corresponds to Return-Path
results.SpfRecords = d.checkSPFRecords(spfDomain)
// Check DKIM records (from authentication results)
// DKIM can be for any domain, but typically the From domain
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && dkim.Selector != nil {
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
// Check DKIM records by parsing DKIM-Signature headers directly
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
dkimRecord := d.checkDKIMRecord(sig)
if dkimRecord != nil {
if results.DkimRecords == nil {
results.DkimRecords = new([]api.DKIMRecord)
results.DkimRecords = new([]model.DKIMRecord)
}
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
}
}
}
}
// Check DMARC record (for From domain - DMARC protects the visible sender)
// DMARC validates alignment between SPF/DKIM and the From domain
@ -132,8 +146,8 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
// AnalyzeDomainOnly performs DNS validation for a domain without email context
// This is useful for checking domain configuration without sending an actual email
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
results := &api.DNSResults{
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
results := &model.DNSResults{
FromDomain: domain,
}
@ -143,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
// Check SPF records
results.SpfRecords = d.checkSPFRecords(domain)
// Verify the domain can receive replies/bounces (MX, with A/AAAA fallback)
results.ReturnOk = &model.ReturnOK{
From: d.checkReturnOKDomain(domain, ""),
}
// Check DMARC record
results.DmarcRecord = d.checkDMARCRecord(domain)
@ -155,7 +174,7 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
// Returns a score from 0-100 where higher is better
// This version excludes PTR and DKIM checks since they require email context
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) {
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) {
if results == nil {
return 0, ""
}
@ -174,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, st
// DMARC Record: 40 points
score += 40 * d.calculateDMARCScore(results) / 100
// Penalty when a sender domain cannot receive replies/bounces at all
score += calculateReturnOKPenalty(results)
// BIMI Record: only bonus
if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 {
@ -197,7 +219,7 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, st
// CalculateDNSScore calculates the DNS score from records results
// Returns a score from 0-100 where higher is better
// senderIP is the original sender IP address used for FCrDNS verification
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) {
func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) {
if results == nil {
return 0, ""
}
@ -219,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string
// DMARC Record: 20 points
score += 20 * d.calculateDMARCScore(results) / 100
// Penalty when a sender domain cannot receive replies/bounces at all
score += calculateReturnOKPenalty(results)
// BIMI Record
// BIMI is optional but indicates advanced email branding
if results.BimiRecord != nil && results.BimiRecord.Valid {

View file

@ -27,11 +27,12 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord {
// BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
@ -40,20 +41,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil {
return &api.BIMIRecord{
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %s", formatDNSError(err))),
}
}
if len(txtRecords) == 0 {
return &api.BIMIRecord{
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.PtrTo("No BIMI record found"),
Error: utils.PtrTo("No BIMI record found"),
}
}
@ -66,18 +67,18 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) {
return &api.BIMIRecord{
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: false,
Error: api.PtrTo("BIMI record appears malformed"),
Error: utils.PtrTo("BIMI record appears malformed"),
}
}
return &api.BIMIRecord{
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,

View file

@ -23,70 +23,178 @@ package analyzer
import (
"context"
"crypto/x509"
"encoding/base64"
"fmt"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header.
type DKIMHeader struct {
Domain string
Selector string
Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256)
}
// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values.
func parseDKIMSignatures(signatures []string) []DKIMHeader {
var results []DKIMHeader
for _, sig := range signatures {
var domain, selector, algorithm 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
case "a":
algorithm = val
}
}
if domain != "" && selector != "" {
results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
}
}
return results
}
// parseDKIMTags splits a DKIM DNS record into a tag→value map.
func parseDKIMTags(record string) map[string]string {
tags := make(map[string]string)
for _, part := range strings.Split(record, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
return tags
}
// parseKeySize derives the public key bit length from a base64-encoded DER public key.
// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256.
func parseKeySize(keyType, p string) *int {
switch strings.ToLower(keyType) {
case "ed25519":
return utils.PtrTo(256)
case "rsa", "":
der, err := base64.StdEncoding.DecodeString(p)
if err != nil {
// Try without padding
der, err = base64.RawStdEncoding.DecodeString(p)
if err != nil {
return nil
}
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil
}
if rsaPub, ok := pub.(interface{ Size() int }); ok {
bits := rsaPub.Size() * 8
return &bits
}
return nil
}
return nil
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector.
func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
return &model.DKIMRecord{
Selector: h.Selector,
Domain: h.Domain,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %s", formatDNSError(err))),
}
}
if len(txtRecords) == 0 {
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
return &model.DKIMRecord{
Selector: h.Selector,
Domain: h.Domain,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Valid: false,
Error: api.PtrTo("No DKIM record found"),
Error: utils.PtrTo("No DKIM record found"),
}
}
// Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) {
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Record: api.PtrTo(dkimRecord),
return &model.DKIMRecord{
Selector: h.Selector,
Domain: h.Domain,
Record: utils.PtrTo(dkimRecord),
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Valid: false,
Error: api.PtrTo("DKIM record appears malformed"),
Error: utils.PtrTo("DKIM record appears malformed"),
}
}
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
tags := parseDKIMTags(dkimRecord)
keyType := tags["k"]
if keyType == "" {
keyType = "rsa" // RFC 6376 default
}
var hashAlgorithms []string
if h, ok := tags["h"]; ok && h != "" {
for _, alg := range strings.Split(h, ":") {
if a := strings.TrimSpace(alg); a != "" {
hashAlgorithms = append(hashAlgorithms, a)
}
}
}
if hashAlgorithms == nil {
hashAlgorithms = []string{}
}
return &model.DKIMRecord{
Selector: h.Selector,
Domain: h.Domain,
Record: &dkimRecord,
KeyType: utils.PtrTo(keyType),
HashAlgorithms: &hashAlgorithms,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
KeySize: parseKeySize(keyType, tags["p"]),
Valid: true,
}
}
// validateDKIM performs basic DKIM record validation
func signingAlgorithmPtr(a string) *string {
if a == "" {
return nil
}
return &a
}
// validateDKIM performs basic DKIM record validation.
func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") {
return false
}
// Often contains v=DKIM1 but not required
// If v= is present, it should be DKIM1
// If v= is present, it must be DKIM1
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false
}
@ -94,22 +202,58 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
return true
}
func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) {
// DKIM provides strong email authentication
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
hasValidDKIM := false
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
return 0
}
hasValid := false
for _, dkim := range *results.DkimRecords {
if dkim.Valid {
hasValidDKIM = true
hasValid = true
break
}
}
if hasValidDKIM {
score += 100
} else {
// Partial credit if DKIM record exists but has issues
score += 25
if !hasValid {
return 25
}
score = 100
// Apply security penalties on the best valid record
for _, dkim := range *results.DkimRecords {
if !dkim.Valid {
continue
}
// SHA-1 signing is deprecated (RFC 8301)
if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") {
if score > 60 {
score = 60
}
}
// Key size penalties apply only to RSA
keyType := ""
if dkim.KeyType != nil {
keyType = strings.ToLower(*dkim.KeyType)
}
if keyType == "rsa" || keyType == "" {
if dkim.KeySize != nil {
switch {
case *dkim.KeySize < 1024:
if score > 25 {
score = 25
}
case *dkim.KeySize < 2048:
if score > 75 {
score = 75
}
}
}
}
// Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty.
}
return

View file

@ -22,10 +22,231 @@
package analyzer
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"testing"
"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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "ed25519-sha256"}},
},
{
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", Algorithm: "rsa-sha256"},
{Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"},
},
},
{
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", Algorithm: "ed25519-sha256"},
{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"},
},
},
{
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", Algorithm: "rsa-sha256"},
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"},
},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha1"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"}},
},
{
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", Algorithm: "rsa-sha256"},
{Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"},
},
},
}
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)
}
if result[i].Algorithm != tt.expected[i].Algorithm {
t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm)
}
}
})
}
}
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string
@ -70,3 +291,119 @@ func TestValidateDKIM(t *testing.T) {
})
}
}
func TestParseDKIMTags(t *testing.T) {
tests := []struct {
name string
record string
wantTags map[string]string
}{
{
name: "standard RSA record",
record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256",
wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"},
},
{
name: "ed25519 record",
record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS",
wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"},
},
{
name: "missing k= defaults",
record: "v=DKIM1; p=MIIBI",
wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"},
},
{
name: "empty record",
record: "",
wantTags: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDKIMTags(tt.record)
for key, want := range tt.wantTags {
if got[key] != want {
t.Errorf("tag %q = %q, want %q", key, got[key], want)
}
}
})
}
}
func TestParseKeySize(t *testing.T) {
// Generate a real RSA key for testing
rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024)
rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048)
der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey)
der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey)
p1024 := base64.StdEncoding.EncodeToString(der1024)
p2048 := base64.StdEncoding.EncodeToString(der2048)
tests := []struct {
name string
keyType string
p string
want *int
}{
{
name: "RSA 1024",
keyType: "rsa",
p: p1024,
want: intPtr(1024),
},
{
name: "RSA 2048",
keyType: "rsa",
p: p2048,
want: intPtr(2048),
},
{
name: "Ed25519 always 256",
keyType: "ed25519",
p: "11qYAYKxCrfVS",
want: intPtr(256),
},
{
name: "Unknown key type",
keyType: "unknown",
p: "somedata",
want: nil,
},
{
name: "Invalid RSA base64",
keyType: "rsa",
p: "!!!not-base64!!!",
want: nil,
},
{
name: "Empty k= defaults to RSA",
keyType: "",
p: p2048,
want: intPtr(2048),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseKeySize(tt.keyType, tt.p)
if tt.want == nil {
if got != nil {
t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got)
}
return
}
if got == nil {
t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want)
}
if *got != *tt.want {
t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want)
}
})
}
}
func intPtr(v int) *int { return &v }

View file

@ -24,233 +24,291 @@ package analyzer
import (
"context"
"fmt"
"regexp"
"net"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2}
// lookupDMARCAt queries _dmarc.<domain> and returns the raw DMARC1 TXT record.
// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred.
func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &api.DMARCRecord{
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain))
if lookupErr != nil {
if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound {
return "", true, nil
}
return "", false, lookupErr
}
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
return txt, false, nil
}
}
return "", true, nil
}
// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model.
func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
tags := parseDKIMTags(rawRecord)
// Policy
policy := "unknown"
switch tags["p"] {
case "none", "quarantine", "reject":
policy = tags["p"]
}
// SPF alignment (default: relaxed)
spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
if tags["aspf"] == "s" {
spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
}
// DKIM alignment (default: relaxed)
dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
if tags["adkim"] == "s" {
dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
}
// Subdomain policy
var subdomainPolicy *model.DMARCRecordSubdomainPolicy
switch tags["sp"] {
case "none", "quarantine", "reject":
subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"]))
}
// Non-existent subdomain policy (DMARCbis np=)
var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy
switch tags["np"] {
case "none", "quarantine", "reject":
nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"]))
}
// Percentage (pct=, deprecated in DMARCbis)
var percentage *int
if pctStr, ok := tags["pct"]; ok {
if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 {
percentage = &pct
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
// Test mode (DMARCbis t=)
var testMode *bool
if t, ok := tags["t"]; ok {
v := t == "y"
testMode = &v
}
// PSD (DMARCbis psd=)
var psd *model.DMARCRecordPsd
switch tags["psd"] {
case "y", "n", "u":
psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"]))
}
rec := &model.DMARCRecord{
Domain: &foundDomain,
Record: &rawRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
Percentage: percentage,
TestMode: testMode,
Psd: psd,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
}
if percentage != nil {
rec.DeprecatedPct = utils.PtrTo(true)
}
if _, ok := tags["rf"]; ok {
rec.DeprecatedRf = utils.PtrTo(true)
}
if _, ok := tags["ri"]; ok {
rec.DeprecatedRi = utils.PtrTo(true)
}
if !d.validateDMARC(rawRecord) {
rec.Valid = false
rec.Error = utils.PtrTo("DMARC record appears malformed")
return rec
}
rec.Valid = true
return rec
}
// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10).
// It queries _dmarc.<domain> and walks up the label hierarchy until a valid DMARC
// record is found or all labels are exhausted. Maximum 8 DNS queries per message.
// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label
// suffix before resuming normally (to stay within the 8-query budget).
// Single-label (TLD) records are only accepted when they carry psd=y.
func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) {
labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".")
n := len(labels)
for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 {
current := strings.Join(labels[i:], ".")
raw, notFound, lookupErr := d.lookupDMARCAt(current)
if lookupErr != nil {
return "", "", lookupErr
}
if !notFound {
// Single-label (TLD) records are only used when the record explicitly opts in.
if !strings.Contains(current, ".") {
if d.extractDMARCPSDValue(raw) != "y" {
break
}
}
return raw, current, nil
}
if dmarcRecord == "" {
return &api.DMARCRecord{
// DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the
// 7-label suffix for the next query rather than stepping one label at a time.
if i == 0 && n >= 8 {
i = n - 8 // the outer i++ will land at n-7 (7 labels from the right)
}
}
return "", "", nil
}
// checkDMARCRecord looks up and validates the DMARC record for a domain using
// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the
// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC
// experimental fallback.
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
raw, foundDomain, err := d.walkDNSForDMARC(domain)
if err != nil {
return &model.DMARCRecord{
Valid: false,
Error: api.PtrTo("No DMARC record found"),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %s", formatDNSError(err))),
}
}
// Extract policy
policy := d.extractDMARCPolicy(dmarcRecord)
// Extract subdomain policy
subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord)
// Extract percentage
percentage := d.extractDMARCPercentage(dmarcRecord)
// Extract alignment modes
spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord)
dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord)
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &api.DMARCRecord{
Record: &dmarcRecord,
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
if foundDomain == "" {
return &model.DMARCRecord{
Valid: false,
Error: api.PtrTo("DMARC record appears malformed"),
Error: utils.PtrTo("No DMARC record found"),
}
}
return &api.DMARCRecord{
Record: &dmarcRecord,
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: true,
}
return d.parseDMARCRecord(foundDomain, raw)
}
// extractDMARCPolicy extracts the policy from a DMARC record
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// Look for p=none, p=quarantine, or p=reject
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent.
// Used during DNS Tree Walk before full record parsing.
func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
v := parseDKIMTags(record)["psd"]
switch v {
case "y", "n", "u":
return v
}
return "unknown"
return ""
}
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment {
// Look for aspf=s (strict) or aspf=r (relaxed)
re := regexp.MustCompile(`aspf=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
}
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
}
// Default is relaxed if not specified
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
}
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
// Look for adkim=s (strict) or adkim=r (relaxed)
re := regexp.MustCompile(`adkim=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
}
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
}
// Default is relaxed if not specified
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
}
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
// Returns the sp tag value or nil if not specified (defaults to main policy)
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
// Look for sp=none, sp=quarantine, or sp=reject
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
}
// If sp is not specified, it defaults to the main policy (p tag)
// Return nil to indicate it's using the default
return nil
}
// extractDMARCPercentage extracts the percentage from a DMARC record
// Returns the pct tag value or nil if not specified (defaults to 100)
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
// Look for pct=<number>
re := regexp.MustCompile(`pct=(\d+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
// Convert string to int
var pct int
fmt.Sscanf(matches[1], "%d", &pct)
// Validate range (0-100)
if pct >= 0 && pct <= 100 {
return &pct
}
}
// Default is 100 if not specified
return nil
}
// validateDMARC performs basic DMARC record validation
// validateDMARC performs basic DMARC record validation.
// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
// rua= but no p= is treated as p=none and considered valid.
func (d *DNSAnalyzer) validateDMARC(record string) bool {
// Must start with v=DMARC1
if !strings.HasPrefix(record, "v=DMARC1") {
return false
}
// Must have a policy tag
// p= absent is allowed in DMARCbis when rua= is present (treated as p=none).
if !strings.Contains(record, "p=") {
return false
return strings.Contains(record, "rua=")
}
return true
}
func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
// DMARC ties SPF and DKIM together and provides policy
if results.DmarcRecord != nil {
if results.DmarcRecord.Valid {
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
if results.DmarcRecord == nil {
return
}
if !results.DmarcRecord.Valid {
if results.DmarcRecord.Record != nil {
// Partial credit if a DMARC record exists but has issues
score += 20
}
return
}
score += 50
// Bonus points for stricter policies
// Determine effective policy: DMARCbis t=y downgrades policy one level.
effectivePolicy := "none"
if results.DmarcRecord.Policy != nil {
switch *results.DmarcRecord.Policy {
effectivePolicy = string(*results.DmarcRecord.Policy)
}
testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode
if testMode {
switch effectivePolicy {
case "reject":
// Strictest policy - full points already awarded
score += 25
effectivePolicy = "quarantine"
case "quarantine":
// Good policy - no deduction
effectivePolicy = "none"
}
}
// Bonus/penalty for policy strength
switch effectivePolicy {
case "reject":
score += 25
case "none":
// Weakest policy - deduct 5 points
score -= 25
}
}
// Bonus points for strict alignment modes (2 points each)
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict {
// Bonus points for strict alignment modes
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
score += 5
}
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict {
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
score += 5
}
// Subdomain policy scoring (sp tag)
// +3 for stricter or equal subdomain policy, -3 for weaker
// Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker
if results.DmarcRecord.SubdomainPolicy != nil {
mainPolicy := string(*results.DmarcRecord.Policy)
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
// Policy strength: none < quarantine < reject
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
mainStrength := policyStrength[mainPolicy]
subStrength := policyStrength[subPolicy]
if subStrength >= mainStrength {
// Subdomain policy is equal or stricter
if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] {
score += 15
} else {
// Subdomain policy is weaker
score -= 15
}
} else {
// No sp tag means subdomains inherit main policy (good default)
score += 15
score += 15 // inherits main policy — good default
}
// Percentage scoring (pct tag)
// Apply the percentage on the current score
// Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker
effectiveSubPolicy := effectivePolicy
if results.DmarcRecord.SubdomainPolicy != nil {
effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
}
if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
score += 15 // inherits subdomain/main policy — good default
} else if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] {
score += 15
} else {
score -= 15
}
// pct= scaling (deprecated in DMARCbis, kept for backward compatibility).
// pct=0 is an anti-pattern: score it as zero enforcement.
if results.DmarcRecord.Percentage != nil {
pct := *results.DmarcRecord.Percentage
score = score * pct / 100
}
} else if results.DmarcRecord.Record != nil {
// Partial credit if DMARC record exists but has issues
score += 20
}
}
return
}

View file

@ -22,13 +22,206 @@
package analyzer
import (
"context"
"fmt"
"net"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestExtractDMARCPolicy(t *testing.T) {
// mockDNSResolver maps domain names to TXT records for testing.
// An entry with value nil means NXDOMAIN; an error value triggers a DNS error.
type mockDNSResolver struct {
txt map[string][]string
err map[string]error
}
func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
if err, ok := m.err[name]; ok {
return nil, err
}
if records, ok := m.txt[name]; ok {
return records, nil
}
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) {
return nil, nil
}
func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer {
if errMap == nil {
errMap = map[string]error{}
}
return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap})
}
func TestCheckDMARCRecordFallback(t *testing.T) {
const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
const subRecord = "v=DMARC1; p=reject"
const psdRecord = "v=DMARC1; p=none; psd=y"
tests := []struct {
name string
domain string
txt map[string][]string
errMap map[string]error
wantValid bool
wantDomain *string
wantErrSubst string
}{
{
name: "exact domain has DMARC record — no fallback",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.mail.example.com": {subRecord},
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("mail.example.com"),
},
{
name: "exact domain NXDOMAIN — tree walk reaches org domain",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("example.com"),
},
{
name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.mail.example.com": {"some-other-txt"},
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("example.com"),
},
{
name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.com": {psdRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("com"),
},
{
name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.com": {"v=DMARC1; p=none"},
},
wantValid: false,
wantErrSubst: "No DMARC record found",
},
{
name: "no record at any level",
domain: "mail.example.com",
txt: map[string][]string{},
wantValid: false,
wantErrSubst: "No DMARC record found",
},
{
name: "DNS error on exact domain — error returned",
domain: "mail.example.com",
errMap: map[string]error{
"_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
},
wantValid: false,
wantErrSubst: "SERVFAIL",
},
{
name: "domain already at org level — found immediately",
domain: "example.com",
txt: map[string][]string{
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("example.com"),
},
{
name: "deep subdomain — tree walk finds record two levels up",
domain: "a.b.example.com",
txt: map[string][]string{
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("example.com"),
},
{
name: "8-label domain — shortcut to 7-label suffix on miss",
domain: "a.b.c.d.e.f.example.com",
txt: map[string][]string{
"_dmarc.b.c.d.e.f.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("b.c.d.e.f.example.com"),
},
{
name: "psd=n record stops tree walk at that level",
domain: "mail.sub.example.com",
txt: map[string][]string{
"_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"},
},
wantValid: true,
wantDomain: utils.PtrTo("sub.example.com"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := newMockAnalyzer(tt.txt, tt.errMap)
result := analyzer.checkDMARCRecord(tt.domain)
if result.Valid != tt.wantValid {
t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid)
}
if tt.wantDomain != nil {
if result.Domain == nil {
t.Fatalf("Domain = nil, want %q", *tt.wantDomain)
}
if *result.Domain != *tt.wantDomain {
t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain)
}
}
if tt.wantErrSubst != "" {
if result.Error == nil {
t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst)
}
if !contains(*result.Error, tt.wantErrSubst) {
t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst)
}
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func TestParseDMARCRecordPolicy(t *testing.T) {
tests := []struct {
name string
record string
@ -60,9 +253,135 @@ func TestExtractDMARCPolicy(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPolicy(tt.record)
if result != tt.expectedPolicy {
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
rec := analyzer.parseDMARCRecord("example.com", tt.record)
if rec.Policy == nil {
t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record)
}
if string(*rec.Policy) != tt.expectedPolicy {
t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy)
}
})
}
}
func TestParseDMARCRecordTestMode(t *testing.T) {
tests := []struct {
name string
record string
wantMode *bool
}{
{
name: "t=y sets test mode",
record: "v=DMARC1; p=reject; t=y",
wantMode: utils.PtrTo(true),
},
{
name: "t=n explicitly disables test mode",
record: "v=DMARC1; p=reject; t=n",
wantMode: utils.PtrTo(false),
},
{
name: "absent t tag returns nil",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
wantMode: nil,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode
if tt.wantMode == nil {
if result != nil {
t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode)
}
if *result != *tt.wantMode {
t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode)
}
}
})
}
}
func TestParseDMARCRecordPSD(t *testing.T) {
tests := []struct {
name string
record string
wantPSD *string
}{
{
name: "psd=y marks Public Suffix Domain",
record: "v=DMARC1; p=none; psd=y",
wantPSD: utils.PtrTo("y"),
},
{
name: "psd=n marks Org Domain boundary",
record: "v=DMARC1; p=reject; psd=n",
wantPSD: utils.PtrTo("n"),
},
{
name: "psd=u is explicit unknown",
record: "v=DMARC1; p=quarantine; psd=u",
wantPSD: utils.PtrTo("u"),
},
{
name: "absent psd tag returns nil",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
wantPSD: nil,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseDMARCRecord("example.com", tt.record).Psd
if tt.wantPSD == nil {
if result != nil {
t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD)
}
if string(*result) != *tt.wantPSD {
t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD)
}
}
})
}
}
func TestParseDMARCRecordDeprecatedTags(t *testing.T) {
tests := []struct {
name string
record string
wantRf bool
wantRi bool
}{
{name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false},
{name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true},
{name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false},
{name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := analyzer.parseDMARCRecord("example.com", tt.record)
gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf
gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi
if gotRf != tt.wantRf {
t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf)
}
if gotRi != tt.wantRi {
t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi)
}
})
}
@ -84,13 +403,18 @@ func TestValidateDMARC(t *testing.T) {
record: "v=DMARC1; p=none",
expected: true,
},
{
name: "DMARCbis: p= absent but rua= present is valid (treated as p=none)",
record: "v=DMARC1; rua=mailto:dmarc@example.com",
expected: true,
},
{
name: "Invalid DMARC - no version",
record: "p=quarantine",
expected: false,
},
{
name: "Invalid DMARC - no policy",
name: "Invalid DMARC - no policy and no rua",
record: "v=DMARC1",
expected: false,
},
@ -113,41 +437,36 @@ func TestValidateDMARC(t *testing.T) {
}
}
func TestExtractDMARCSPFAlignment(t *testing.T) {
func TestParseDMARCRecordAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment string
expectedSPF string
expectedDKIM string
}{
{
name: "SPF alignment - strict",
record: "v=DMARC1; p=quarantine; aspf=s",
expectedAlignment: "strict",
},
{
name: "SPF alignment - relaxed (explicit)",
record: "v=DMARC1; p=quarantine; aspf=r",
expectedAlignment: "relaxed",
},
{
name: "SPF alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=quarantine",
expectedAlignment: "relaxed",
},
{
name: "Both alignments specified - check SPF strict",
name: "SPF strict, DKIM relaxed",
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedAlignment: "strict",
expectedSPF: "strict",
expectedDKIM: "relaxed",
},
{
name: "Both alignments specified - check SPF relaxed",
name: "SPF relaxed explicit, DKIM strict",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "relaxed",
expectedSPF: "relaxed",
expectedDKIM: "strict",
},
{
name: "Complex record with SPF strict",
name: "Defaults when neither specified",
record: "v=DMARC1; p=quarantine",
expectedSPF: "relaxed",
expectedDKIM: "relaxed",
},
{
name: "Both strict in complex record",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
expectedAlignment: "strict",
expectedSPF: "strict",
expectedDKIM: "strict",
},
}
@ -155,100 +474,53 @@ func TestExtractDMARCSPFAlignment(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSPFAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
rec := analyzer.parseDMARCRecord("example.com", tt.record)
if rec.SpfAlignment == nil {
t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record)
}
if string(*result) != tt.expectedAlignment {
t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
if string(*rec.SpfAlignment) != tt.expectedSPF {
t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF)
}
if rec.DkimAlignment == nil {
t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record)
}
if string(*rec.DkimAlignment) != tt.expectedDKIM {
t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM)
}
})
}
}
func TestExtractDMARCDKIMAlignment(t *testing.T) {
func TestParseDMARCRecordSubdomainPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment string
expectedSP *string
expectedNP *string
}{
{
name: "DKIM alignment - strict",
record: "v=DMARC1; p=reject; adkim=s",
expectedAlignment: "strict",
},
{
name: "DKIM alignment - relaxed (explicit)",
record: "v=DMARC1; p=reject; adkim=r",
expectedAlignment: "relaxed",
},
{
name: "DKIM alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=none",
expectedAlignment: "relaxed",
},
{
name: "Both alignments specified - check DKIM strict",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "strict",
},
{
name: "Both alignments specified - check DKIM relaxed",
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedAlignment: "relaxed",
},
{
name: "Complex record with DKIM strict",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100",
expectedAlignment: "strict",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCDKIMAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record)
}
if string(*result) != tt.expectedAlignment {
t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
}
})
}
}
func TestExtractDMARCSubdomainPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy *string
}{
{
name: "Subdomain policy - none",
name: "sp=none, no np",
record: "v=DMARC1; p=quarantine; sp=none",
expectedPolicy: api.PtrTo("none"),
expectedSP: utils.PtrTo("none"),
expectedNP: nil,
},
{
name: "Subdomain policy - quarantine",
record: "v=DMARC1; p=reject; sp=quarantine",
expectedPolicy: api.PtrTo("quarantine"),
name: "sp=reject, np=reject",
record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100",
expectedSP: utils.PtrTo("quarantine"),
expectedNP: utils.PtrTo("reject"),
},
{
name: "Subdomain policy - reject",
record: "v=DMARC1; p=quarantine; sp=reject",
expectedPolicy: api.PtrTo("reject"),
},
{
name: "No subdomain policy specified (defaults to main policy)",
name: "No sp or np (both default)",
record: "v=DMARC1; p=quarantine",
expectedPolicy: nil,
expectedSP: nil,
expectedNP: nil,
},
{
name: "Complex record with subdomain policy",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
expectedPolicy: api.PtrTo("quarantine"),
name: "np=quarantine, no sp",
record: "v=DMARC1; p=reject; np=quarantine",
expectedSP: nil,
expectedNP: utils.PtrTo("quarantine"),
},
}
@ -256,86 +528,63 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSubdomainPolicy(tt.record)
if tt.expectedPolicy == nil {
if result != nil {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result)
rec := analyzer.parseDMARCRecord("example.com", tt.record)
if tt.expectedSP == nil {
if rec.SubdomainPolicy != nil {
t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy)
}
} else {
if result == nil {
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
if rec.SubdomainPolicy == nil {
t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP)
}
if string(*result) != *tt.expectedPolicy {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
if string(*rec.SubdomainPolicy) != *tt.expectedSP {
t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP)
}
}
if tt.expectedNP == nil {
if rec.NonexistentSubdomainPolicy != nil {
t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy)
}
} else {
if rec.NonexistentSubdomainPolicy == nil {
t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP)
}
if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP {
t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP)
}
}
})
}
}
func TestExtractDMARCPercentage(t *testing.T) {
func TestParseDMARCRecordPercentage(t *testing.T) {
tests := []struct {
name string
record string
expectedPercentage *int
}{
{
name: "Percentage - 100",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPercentage: api.PtrTo(100),
},
{
name: "Percentage - 50",
record: "v=DMARC1; p=quarantine; pct=50",
expectedPercentage: api.PtrTo(50),
},
{
name: "Percentage - 25",
record: "v=DMARC1; p=reject; pct=25",
expectedPercentage: api.PtrTo(25),
},
{
name: "Percentage - 0",
record: "v=DMARC1; p=none; pct=0",
expectedPercentage: api.PtrTo(0),
},
{
name: "No percentage specified (defaults to 100)",
record: "v=DMARC1; p=quarantine",
expectedPercentage: nil,
},
{
name: "Complex record with percentage",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
expectedPercentage: api.PtrTo(75),
},
{
name: "Invalid percentage > 100 (ignored)",
record: "v=DMARC1; p=quarantine; pct=150",
expectedPercentage: nil,
},
{
name: "Invalid percentage < 0 (ignored)",
record: "v=DMARC1; p=quarantine; pct=-10",
expectedPercentage: nil,
},
{name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)},
{name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)},
{name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)},
{name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil},
{name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPercentage(tt.record)
result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage
if tt.expectedPercentage == nil {
if result != nil {
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage)
t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage)
}
if *result != *tt.expectedPercentage {
t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage)
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage)
}
}
})

View file

@ -23,8 +23,9 @@ package analyzer
import (
"context"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
@ -62,8 +63,23 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
return ptrNames, forwardIPs
}
// checkHeloPtrMatch reports whether the announced HELO hostname matches one of
// the sender's PTR records (case-insensitive, trailing dot ignored).
func checkHeloPtrMatch(helo string, ptrRecords []string) bool {
helo = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(helo)), ".")
if helo == "" {
return false
}
for _, ptr := range ptrRecords {
if strings.TrimSuffix(strings.ToLower(ptr), ".") == helo {
return true
}
}
return false
}
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) {
func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) {
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
// 50 points for having PTR records
score += 50
@ -73,6 +89,11 @@ func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string
score -= 15
}
// Penalty when the announced HELO name doesn't match the PTR hostname
if results.HeloPtrMatch != nil && !*results.HeloPtrMatch {
score -= 15
}
// Additional 50 points for forward-confirmed reverse DNS (FCrDNS)
// This means the PTR hostname resolves back to IPs that include the original sender IP
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {

View file

@ -0,0 +1,104 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestCheckHeloPtrMatch(t *testing.T) {
tests := []struct {
name string
helo string
ptrRecords []string
want bool
}{
{"exact match", "mail.example.com", []string{"mail.example.com"}, true},
{"case insensitive", "Mail.Example.COM", []string{"mail.example.com"}, true},
{"trailing dot ignored", "mail.example.com.", []string{"mail.example.com"}, true},
{"mismatch", "relay.example.org", []string{"mail.example.com"}, false},
{"match among several", "smtp.example.com", []string{"mail.example.com", "smtp.example.com"}, true},
{"empty helo", "", []string{"mail.example.com"}, false},
{"no ptr records", "mail.example.com", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkHeloPtrMatch(tt.helo, tt.ptrRecords); got != tt.want {
t.Errorf("checkHeloPtrMatch(%q, %v) = %v, want %v", tt.helo, tt.ptrRecords, got, tt.want)
}
})
}
}
func TestCalculatePTRScoreHeloMismatch(t *testing.T) {
d := NewDNSAnalyzer(0)
senderIP := "80.67.179.207"
ptr := []string{"mail.example.com"}
forward := []string{senderIP}
matchTrue := true
matchFalse := false
tests := []struct {
name string
results *model.DNSResults
want int
}{
{
name: "helo matches ptr - no penalty (PTR+FCrDNS)",
results: &model.DNSResults{
PtrRecords: &ptr,
PtrForwardRecords: &forward,
HeloPtrMatch: &matchTrue,
},
want: 100,
},
{
name: "helo mismatch - 15 point penalty",
results: &model.DNSResults{
PtrRecords: &ptr,
PtrForwardRecords: &forward,
HeloPtrMatch: &matchFalse,
},
want: 85,
},
{
name: "no helo info - no penalty",
results: &model.DNSResults{
PtrRecords: &ptr,
PtrForwardRecords: &forward,
},
want: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := d.calculatePTRScore(tt.results, senderIP); got != tt.want {
t.Errorf("calculatePTRScore() = %d, want %d", got, tt.want)
}
})
}
}

View file

@ -25,36 +25,37 @@ import (
"context"
"fmt"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return &[]api.MXRecord{
return &[]model.MXRecord{
{
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %s", formatDNSError(err))),
},
}
}
if len(mxRecords) == 0 {
return &[]api.MXRecord{
return &[]model.MXRecord{
{
Valid: false,
Error: api.PtrTo("No MX records found"),
Error: utils.PtrTo("No MX records found"),
},
}
}
var results []api.MXRecord
var results []model.MXRecord
for _, mx := range mxRecords {
results = append(results, api.MXRecord{
results = append(results, model.MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
@ -64,7 +65,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
return &results
}
func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) {
func (d *DNSAnalyzer) calculateMXScore(results *model.DNSResults) (score int) {
// Having valid MX records is critical for email deliverability
// From domain MX records (half points) - needed for replies
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {

View file

@ -23,9 +23,22 @@ package analyzer
import (
"context"
"errors"
"net"
)
// formatDNSError renders a resolution error without exposing the upstream
// resolver address that net.DNSError.Error() normally appends as " on <addr>".
func formatDNSError(err error) string {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
sanitized := *dnsErr
sanitized.Server = ""
return sanitized.Error()
}
return err.Error()
}
// DNSResolver defines the interface for DNS resolution operations.
// This interface abstracts DNS lookups to allow for custom implementations,
// such as mock resolvers for testing or caching resolvers for performance.

View file

@ -0,0 +1,113 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// ReturnOKDomain.Status values, matching the schema enum. Kept as a plain string
// in the generated model (x-go-type) to avoid colliding with other "pass"/"fail"
// enums in the global enum namespace.
const (
returnOKStatusPass = "pass"
returnOKStatusWarn = "warn"
returnOKStatusFail = "fail"
)
// domainCanReceive reports whether a domain can accept mail, looking up records
// in the same order as Fastmail's ReturnOK milter: MX first, then A/AAAA.
func (d *DNSAnalyzer) domainCanReceive(domain string) (hasMX, hasAddress bool) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
if mxRecords, err := d.resolver.LookupMX(ctx, domain); err == nil && len(mxRecords) > 0 {
return true, false
}
if addrs, err := d.resolver.LookupHost(ctx, domain); err == nil && len(addrs) > 0 {
return false, true
}
return false, false
}
// checkReturnOKDomain verifies that a domain can receive replies/bounces.
// It checks the domain itself, then falls back to its organizational domain
// (when different) the same way the ReturnOK milter retries the org domain.
func (d *DNSAnalyzer) checkReturnOKDomain(domain, orgDomain string) *model.ReturnOKDomain {
if domain == "" {
return nil
}
result := &model.ReturnOKDomain{Domain: domain}
hasMX, hasAddress := d.domainCanReceive(domain)
// Fall back to the organizational domain when the domain itself has nothing.
if !hasMX && !hasAddress && orgDomain != "" && orgDomain != domain {
if orgMX, orgAddr := d.domainCanReceive(orgDomain); orgMX || orgAddr {
hasMX, hasAddress = orgMX, orgAddr
result.OrgDomain = utils.PtrTo(orgDomain)
}
}
result.HasMx = utils.PtrTo(hasMX)
result.HasAddress = utils.PtrTo(hasAddress)
switch {
case hasMX:
result.Status = returnOKStatusPass
case hasAddress:
result.Status = returnOKStatusWarn
default:
result.Status = returnOKStatusFail
}
return result
}
// calculateReturnOKPenalty returns a non-positive value: each sender domain that
// can receive neither replies nor bounces (status=fail) costs points, since
// those messages would be silently lost.
func calculateReturnOKPenalty(results *model.DNSResults) (penalty int) {
if results.ReturnOk == nil {
return 0
}
for _, dom := range []*model.ReturnOKDomain{results.ReturnOk.From, results.ReturnOk.ReturnPath} {
if dom != nil && dom.Status == returnOKStatusFail {
penalty -= 10
}
}
return
}
// orgDomainOrEmpty dereferences an optional organizational domain pointer.
func orgDomainOrEmpty(orgDomain *string) string {
if orgDomain == nil {
return ""
}
return *orgDomain
}

View file

@ -0,0 +1,170 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"net"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
)
// returnOKMockResolver lets tests control MX and host (A/AAAA) lookups per domain.
type returnOKMockResolver struct {
mx map[string][]*net.MX
hosts map[string][]string
}
func (m *returnOKMockResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
if recs, ok := m.mx[name]; ok {
return recs, nil
}
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (m *returnOKMockResolver) LookupHost(_ context.Context, host string) ([]string, error) {
if recs, ok := m.hosts[host]; ok {
return recs, nil
}
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
func (m *returnOKMockResolver) LookupTXT(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func (m *returnOKMockResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func TestCheckReturnOKDomain(t *testing.T) {
mx := []*net.MX{{Host: "mail.example.com.", Pref: 10}}
tests := []struct {
name string
domain string
orgDomain string
resolver *returnOKMockResolver
wantStatus string
wantHasMX bool
wantHasAddr bool
wantOrgDomain string // "" means OrgDomain should be nil
}{
{
name: "domain with MX passes",
domain: "example.com",
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
wantStatus: returnOKStatusPass,
wantHasMX: true,
wantHasAddr: false,
},
{
name: "no MX but A/AAAA warns",
domain: "example.com",
resolver: &returnOKMockResolver{hosts: map[string][]string{"example.com": {"192.0.2.1"}}},
wantStatus: returnOKStatusWarn,
wantHasMX: false,
wantHasAddr: true,
},
{
name: "fallback to org domain MX",
domain: "sub.example.com",
orgDomain: "example.com",
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
wantStatus: returnOKStatusPass,
wantHasMX: true,
wantHasAddr: false,
wantOrgDomain: "example.com",
},
{
name: "nothing anywhere fails",
domain: "example.com",
orgDomain: "example.com",
resolver: &returnOKMockResolver{},
wantStatus: returnOKStatusFail,
wantHasMX: false,
wantHasAddr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := NewDNSAnalyzerWithResolver(5*time.Second, tt.resolver)
got := d.checkReturnOKDomain(tt.domain, tt.orgDomain)
if got == nil {
t.Fatalf("checkReturnOKDomain returned nil")
}
if got.Status != tt.wantStatus {
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
}
if got.HasMx == nil || *got.HasMx != tt.wantHasMX {
t.Errorf("HasMx = %v, want %v", got.HasMx, tt.wantHasMX)
}
if got.HasAddress == nil || *got.HasAddress != tt.wantHasAddr {
t.Errorf("HasAddress = %v, want %v", got.HasAddress, tt.wantHasAddr)
}
if tt.wantOrgDomain == "" {
if got.OrgDomain != nil {
t.Errorf("OrgDomain = %v, want nil", *got.OrgDomain)
}
} else {
if got.OrgDomain == nil || *got.OrgDomain != tt.wantOrgDomain {
t.Errorf("OrgDomain = %v, want %q", got.OrgDomain, tt.wantOrgDomain)
}
}
})
}
}
func TestCheckReturnOKDomainEmpty(t *testing.T) {
d := NewDNSAnalyzerWithResolver(5*time.Second, &returnOKMockResolver{})
if got := d.checkReturnOKDomain("", ""); got != nil {
t.Errorf("checkReturnOKDomain(\"\") = %v, want nil", got)
}
}
func TestCalculateReturnOKPenalty(t *testing.T) {
fail := &model.ReturnOKDomain{Domain: "a.example", Status: returnOKStatusFail}
pass := &model.ReturnOKDomain{Domain: "b.example", Status: returnOKStatusPass}
warn := &model.ReturnOKDomain{Domain: "c.example", Status: returnOKStatusWarn}
tests := []struct {
name string
results *model.DNSResults
want int
}{
{"nil return_ok", &model.DNSResults{}, 0},
{"both pass", &model.DNSResults{ReturnOk: &model.ReturnOK{From: pass, ReturnPath: pass}}, 0},
{"warn is not penalised", &model.DNSResults{ReturnOk: &model.ReturnOK{From: warn}}, 0},
{"one fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: pass}}, -10},
{"both fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: fail}}, -20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := calculateReturnOKPenalty(tt.results); got != tt.want {
t.Errorf("calculateReturnOKPenalty() = %d, want %d", got, tt.want)
}
})
}
}

View file

@ -27,33 +27,34 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord {
visited := make(map[string]bool)
return d.resolveSPFRecords(domain, visited, 0, true)
}
// resolveSPFRecords recursively resolves SPF records including include: directives
// isMainRecord indicates if this is the primary domain's record (not an included one)
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord {
const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth {
return &[]api.SPFRecord{
return &[]model.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("Maximum SPF include depth exceeded"),
Error: utils.PtrTo("Maximum SPF include depth exceeded"),
},
}
}
// Prevent circular references
if visited[domain] {
return &[]api.SPFRecord{}
return &[]model.SPFRecord{}
}
visited[domain] = true
@ -62,11 +63,11 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &[]api.SPFRecord{
return &[]model.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %s", formatDNSError(err))),
},
}
}
@ -82,23 +83,23 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
}
if spfCount == 0 {
return &[]api.SPFRecord{
return &[]model.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("No SPF record found"),
Error: utils.PtrTo("No SPF record found"),
},
}
}
var results []api.SPFRecord
var results []model.SPFRecord
if spfCount > 1 {
results = append(results, api.SPFRecord{
results = append(results, model.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: false,
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
})
return &results
}
@ -107,28 +108,28 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
validationErr := d.validateSPF(spfRecord, isMainRecord)
// Extract the "all" mechanism qualifier
var allQualifier *api.SPFRecordAllQualifier
var allQualifier *model.SPFRecordAllQualifier
var errMsg *string
if validationErr != nil {
errMsg = api.PtrTo(validationErr.Error())
errMsg = utils.PtrTo(validationErr.Error())
} else {
// Extract qualifier from the "all" mechanism
if strings.HasSuffix(spfRecord, " -all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
} else if strings.HasSuffix(spfRecord, " ~all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
} else if strings.HasSuffix(spfRecord, " +all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
} else if strings.HasSuffix(spfRecord, " ?all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
} else if strings.HasSuffix(spfRecord, " all") {
// Implicit + qualifier (default)
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
}
}
results = append(results, api.SPFRecord{
results = append(results, model.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: validationErr == nil,
@ -301,7 +302,7 @@ func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
return strings.HasSuffix(record, " -all")
}
func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) {
func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) {
// SPF is essential for email authentication
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
// Find the main SPF record by skipping redirects

View file

@ -26,12 +26,14 @@ import (
"net"
"net/mail"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/net/publicsuffix"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// HeaderAnalyzer analyzes email header quality and structure
@ -43,7 +45,7 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
}
// CalculateHeaderScore evaluates email structural quality from header analysis
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
if analysis == nil || analysis.Headers == nil {
return 0, ' '
}
@ -187,7 +189,7 @@ func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
}
// isNoReplyAddress checks if a header check represents a no-reply email address
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
if !headerCheck.Present || headerCheck.Value == nil {
return false
}
@ -243,18 +245,18 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
}
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
if email == nil {
return nil
}
analysis := &api.HeaderAnalysis{}
analysis := &model.HeaderAnalysis{}
// Check for proper MIME structure
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
// Initialize headers map
headers := make(map[string]api.HeaderCheck)
headers := make(map[string]model.HeaderCheck)
// Check required headers
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
@ -308,12 +310,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
}
// checkHeader checks if a header is present and valid
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
value := email.GetHeaderValue(headerName)
present := email.HasHeader(headerName) && value != ""
importanceEnum := api.HeaderCheckImportance(importance)
check := &api.HeaderCheck{
importanceEnum := model.HeaderCheckImportance(importance)
check := &model.HeaderCheck{
Present: present,
Importance: &importanceEnum,
}
@ -374,10 +376,10 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
}
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true),
RelaxedAligned: api.PtrTo(true),
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
alignment := &model.DomainAlignment{
Aligned: utils.PtrTo(true),
RelaxedAligned: utils.PtrTo(true),
}
// Extract From domain
@ -387,7 +389,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
if domain != "" {
alignment.FromDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
orgDomain := getOrganizationalDomain(domain)
alignment.FromOrgDomain = &orgDomain
}
}
@ -399,19 +401,19 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
if domain != "" {
alignment.ReturnPathDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
orgDomain := getOrganizationalDomain(domain)
alignment.ReturnPathOrgDomain = &orgDomain
}
}
// Extract DKIM domains from authentication results
var dkimDomains []api.DKIMDomainInfo
var dkimDomains []model.DKIMDomainInfo
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && *dkim.Domain != "" {
domain := *dkim.Domain
orgDomain := h.getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
orgDomain := getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
Domain: domain,
OrgDomain: orgDomain,
})
@ -541,7 +543,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
func getOrganizationalDomain(domain string) string {
domain = strings.ToLower(strings.TrimSpace(domain))
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
@ -560,18 +562,18 @@ func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
}
// findHeaderIssues identifies issues with headers
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
var issues []api.HeaderIssue
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
var issues []model.HeaderIssue
// Check for missing required headers
requiredHeaders := []string{"From", "Date", "Message-ID"}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
issues = append(issues, api.HeaderIssue{
issues = append(issues, model.HeaderIssue{
Header: header,
Severity: api.HeaderIssueSeverityCritical,
Severity: model.HeaderIssueSeverityCritical,
Message: fmt.Sprintf("Required header '%s' is missing", header),
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
})
}
}
@ -579,19 +581,63 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue
// Check Message-ID format
messageID := email.GetHeaderValue("Message-ID")
if messageID != "" && !h.isValidMessageID(messageID) {
issues = append(issues, api.HeaderIssue{
issues = append(issues, model.HeaderIssue{
Header: "Message-ID",
Severity: api.HeaderIssueSeverityMedium,
Severity: model.HeaderIssueSeverityMedium,
Message: "Message-ID format is invalid",
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
})
}
// Check for fake reply/forward: Subject has Re:/Fwd: prefix but no thread headers
subject := email.GetHeaderValue("Subject")
if h.hasReplyPrefix(subject) && !email.HasHeader("References") && !email.HasHeader("In-Reply-To") {
issues = append(issues, model.HeaderIssue{
Header: "Subject",
Severity: model.HeaderIssueSeverityHigh,
Message: "Subject indicates a reply or forward but no References or In-Reply-To header is present",
Advice: utils.PtrTo("Remove the Re:/Fwd: prefix from the subject, or add References/In-Reply-To headers if this is a genuine reply"),
})
}
return issues
}
// hasReplyPrefix reports whether a subject line starts with a reply or forward prefix.
func (h *HeaderAnalyzer) hasReplyPrefix(subject string) bool {
// Normalize: collapse leading whitespace and make comparison case-insensitive
s := strings.ToLower(strings.TrimSpace(subject))
prefixes := []string{
"re:", // English / universal
"fwd:", // English forward
"fw:", // English forward (short)
"aw:", // German Antwort
"wg:", // German Weitergeleitet
"sv:", // Scandinavian Svar
"vs:", // Finnish Vastaus / Norwegian
"ref:", // Some clients
"rép:", // French Réponse
"tr:", // French Transfert
"odp:", // Polish Odpowiedź
"ynt:", // Turkish Yanıt
"res:", // Portuguese/Spanish Resposta/Respuesta
"enc:", // Spanish Enviado/Reenviado
"vl:", // Dutch Verwijzing
"antw:", // Dutch Antwoord
"rv:", // Norwegian/Swedish
}
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
// parseReceivedChain extracts the chain of Received headers from an email
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
if email == nil || email.Header == nil {
return nil
}
@ -601,7 +647,7 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedH
return nil
}
var chain []api.ReceivedHop
var chain []model.ReceivedHop
for _, receivedValue := range receivedHeaders {
hop := h.parseReceivedHeader(receivedValue)
@ -614,8 +660,8 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedH
}
// parseReceivedHeader parses a single Received header value
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
hop := &api.ReceivedHop{}
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
hop := &model.ReceivedHop{}
// Normalize whitespace - Received headers can span multiple lines
normalized := strings.Join(strings.Fields(receivedValue), " ")
@ -692,5 +738,50 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received
}
}
// Extract TLS details from the Received header parentheticals
// (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)")
hop.Tls = parseReceivedTLS(normalized)
return hop
}
// parseReceivedTLS extracts TLS connection details from a normalized Received header value.
// Returns nil when the hop was not encrypted (no TLS version/cipher found).
func parseReceivedTLS(normalized string) *model.TLSInfo {
tls := &model.TLSInfo{}
found := false
// TLS protocol version, e.g. "using TLSv1.3"
if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
tls.Version = &matches[1]
found = true
}
// Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384"
if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
tls.Cipher = &matches[1]
found = true
}
// Cipher strength, e.g. "(256/256 bits)"
if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 {
if bits, err := strconv.Atoi(matches[1]); err == nil {
tls.Bits = &bits
}
}
if !found {
return nil
}
// Certificate verification status. Postfix emits "(verified OK)" when the peer
// certificate was trusted, "(not verified)" otherwise. "No client certificate
// requested" leaves the field unset (trust is simply not applicable).
if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) {
tls.Verified = utils.PtrTo(true)
} else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) {
tls.Verified = utils.PtrTo(false)
}
return tls
}

View file

@ -27,7 +27,7 @@ import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestCalculateHeaderScore(t *testing.T) {
@ -404,7 +404,7 @@ func TestParseReceivedChain(t *testing.T) {
name string
receivedHeaders []string
expectedHops int
validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop)
}{
{
name: "No Received headers",
@ -417,7 +417,7 @@ func TestParseReceivedChain(t *testing.T) {
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -450,7 +450,7 @@ func TestParseReceivedChain(t *testing.T) {
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
},
expectedHops: 2,
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) != 2 {
t.Fatalf("Expected 2 hops, got %d", len(hops))
}
@ -472,7 +472,7 @@ func TestParseReceivedChain(t *testing.T) {
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -499,7 +499,7 @@ func TestParseReceivedChain(t *testing.T) {
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -527,7 +527,7 @@ func TestParseReceivedChain(t *testing.T) {
"from unknown by localhost",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -677,6 +677,77 @@ func TestParseReceivedHeader(t *testing.T) {
}
}
func TestParseReceivedTLS(t *testing.T) {
tests := []struct {
name string
receivedValue string
expectNil bool
expectVersion *string
expectCipher *string
expectBits *int
expectVerified *bool
}{
{
name: "TLS 1.3 no client certificate",
receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " +
"(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " +
"key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " +
"(No client certificate requested) " +
"by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
expectVersion: strPtr("TLSv1.3"),
expectCipher: strPtr("TLS_AES_256_GCM_SHA384"),
expectBits: intPtr(256),
expectVerified: nil,
},
{
name: "TLS with verified client certificate",
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " +
"(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " +
"(Client CN \"example\", Issuer \"CA\" (verified OK)) " +
"by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
expectVersion: strPtr("TLSv1.2"),
expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"),
expectBits: intPtr(128),
expectVerified: boolPtr(true),
},
{
name: "Plaintext (no TLS)",
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalized := strings.Join(strings.Fields(tt.receivedValue), " ")
tls := parseReceivedTLS(normalized)
if tt.expectNil {
if tls != nil {
t.Fatalf("expected nil TLS info, got %+v", tls)
}
return
}
if tls == nil {
t.Fatal("parseReceivedTLS returned nil")
}
if !equalStrPtr(tls.Version, tt.expectVersion) {
t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion))
}
if !equalStrPtr(tls.Cipher, tt.expectCipher) {
t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher))
}
if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) {
t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits)
}
if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) {
t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified)
}
})
}
}
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
analyzer := NewHeaderAnalyzer()
@ -903,11 +974,155 @@ func TestCheckHeader_DateValidation(t *testing.T) {
}
}
func TestHasReplyPrefix(t *testing.T) {
tests := []struct {
subject string
expected bool
}{
// Positive cases
{"Re: Hello", true},
{"RE: Hello", true},
{"re: Hello", true},
{"Fwd: Hello", true},
{"FWD: Hello", true},
{"fw: Hello", true},
{"FW: Hello", true},
{"Aw: Hallo", true},
{"WG: Weitergeleitet", true},
{"Sv: Hej", true},
{"Vs: Vastaus", true},
{"Ref: something", true},
{"Rép: Bonjour", true},
{"TR: Transféré", true},
{"Odp: Odpowiedź", true},
{"Ynt: Yanıt", true},
{"Res: Resposta", true},
{"Enc: Reenviado", true},
{"Vl: Verwijzing", true},
{"Antw: Antwoord", true},
{"Rv: Svar", true},
// Negative cases
{"Hello", false},
{"", false},
{"react: something", false},
{"reference: check this", false},
{"Resources available", false},
{"Friendly reminder", false},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.subject, func(t *testing.T) {
result := analyzer.hasReplyPrefix(tt.subject)
if result != tt.expected {
t.Errorf("hasReplyPrefix(%q) = %v, want %v", tt.subject, result, tt.expected)
}
})
}
}
func TestFindHeaderIssues_FakeReply(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expectIssueType string // non-empty means we expect an issue containing this substring
}{
{
name: "Re: subject without thread headers",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Re: Your invoice",
},
expectIssueType: "References or In-Reply-To",
},
{
name: "Fwd: subject without thread headers",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Fwd: Important update",
},
expectIssueType: "References or In-Reply-To",
},
{
name: "Re: subject with References header - no issue",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Re: Your invoice",
"References": "<original@example.com>",
},
expectIssueType: "",
},
{
name: "Re: subject with In-Reply-To only - no issue",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Re: Your invoice",
"In-Reply-To": "<original@example.com>",
},
expectIssueType: "",
},
{
name: "Normal subject without thread headers - no issue",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Your invoice",
},
expectIssueType: "",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(tt.headers),
}
issues := analyzer.findHeaderIssues(email)
found := false
for _, issue := range issues {
if strings.Contains(issue.Message, tt.expectIssueType) {
found = true
break
}
}
if tt.expectIssueType != "" && !found {
t.Errorf("expected issue containing %q, but none found (issues: %v)", tt.expectIssueType, issues)
}
if tt.expectIssueType == "" {
for _, issue := range issues {
if strings.Contains(issue.Message, "References or In-Reply-To") {
t.Errorf("unexpected fake-reply issue found: %s", issue.Message)
}
}
}
})
}
}
// Helper functions for testing
func strPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}
func ptrToStr(p *string) string {
if p == nil {
return "<nil>"
@ -1012,16 +1227,16 @@ func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
}
// Create authentication results with DKIM signatures
var authResults *api.AuthenticationResults
var authResults *model.AuthenticationResults
if len(tt.dkimDomains) > 0 {
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
for _, domain := range tt.dkimDomains {
dkimResults = append(dkimResults, api.AuthResult{
Result: api.AuthResultResultPass,
dkimResults = append(dkimResults, model.AuthResult{
Result: model.AuthResultResultPass,
Domain: &domain,
})
}
authResults = &api.AuthenticationResults{
authResults = &model.AuthenticationResults{
Dkim: &dkimResults,
}
}

View file

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

View file

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

View file

@ -27,9 +27,11 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
@ -53,8 +55,6 @@ var DefaultRBLs = []string{
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
"spam.spamrats.com", // SpamRats SPAM
"dyna.spamrats.com", // SpamRats dynamic IPs
"psbl.surriel.com", // PSBL
"dnsbl.dronebl.org", // DroneBL
"bl.mailspike.net", // Mailspike BL
@ -74,6 +74,8 @@ var DefaultInformationalRBLs = []string{
var DefaultDNSWLs = []string{
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
"swl.spamhaus.org", // Spamhaus Safe Whitelist
"wl.mailspike.net", // Mailspike Whitelist
"iadb.isipp.com", // ISIPP Internet Accreditation Database
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
@ -118,7 +120,7 @@ func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *
// DNSListResults represents the results of DNS list checks
type DNSListResults struct {
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
Checks map[string][]model.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
@ -127,7 +129,7 @@ type DNSListResults struct {
// 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][]model.BlacklistCheck),
}
ips := r.extractIPs(email)
@ -157,18 +159,26 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
return results
}
// CheckIP checks a single IP address against all configured lists
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
// CheckIP checks a single IP address against all configured lists in parallel
func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) {
if !r.isPublicIP(ip) {
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
}
var checks []api.BlacklistCheck
listedCount := 0
checks := make([]model.BlacklistCheck, len(r.Lists))
var wg sync.WaitGroup
for _, list := range r.Lists {
check := r.checkIP(ip, list)
checks = append(checks, check)
for i, list := range r.Lists {
wg.Add(1)
go func(i int, list string) {
defer wg.Done()
checks[i] = r.checkIP(ip, list)
}(i, list)
}
wg.Wait()
listedCount := 0
for _, check := range checks {
if check.Listed {
listedCount++
}
@ -232,14 +242,14 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
}
// checkIP checks a single IP against a single DNS list
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
check := api.BlacklistCheck{
func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
check := model.BlacklistCheck{
Rbl: list,
}
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = api.PtrTo("Failed to reverse IP address")
check.Error = utils.PtrTo("Failed to reverse IP address")
return check
}
@ -256,17 +266,17 @@ func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
return check
}
}
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
return check
}
if len(addrs) > 0 {
check.Response = api.PtrTo(addrs[0])
check.Response = utils.PtrTo(addrs[0])
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
check.Listed = false
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
} else {
check.Listed = true
}
@ -292,18 +302,37 @@ func (r *DNSListChecker) reverseIP(ipStr string) string {
}
// CalculateScore calculates the list contribution to deliverability.
// Informational lists are not counted in the score.
func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) {
// Informational lists don't count proportionally; instead, if any
// informational list triggers, a flat 10% penalty is applied regardless
// of how many of them fire.
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 {
return 100, ""
}
scoringListCount := len(r.Lists) - len(r.informationalSet)
if scoringListCount <= 0 {
if results.ListedCount <= 0 || scoringListCount <= 0 {
return 100, "A+"
}
percentage := 100 - results.RelevantListedCount*100/scoringListCount
// A listing on any informational list applies a flat 10% penalty.
informationalPenalty := 0
if results.ListedCount > results.RelevantListedCount {
informationalPenalty = 10
}
percentage := max(0, 100-results.RelevantListedCount*100/scoringListCount-informationalPenalty)
return percentage, ScoreToGrade(percentage)
}

View file

@ -26,7 +26,7 @@ import (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
func TestNewRBLChecker(t *testing.T) {
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
func TestGetBlacklistScore(t *testing.T) {
tests := []struct {
name string
results *RBLResults
results *DNSListResults
expectedScore int
}{
{
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
},
{
name: "No IPs checked",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{},
},
expectedScore: 100,
},
{
name: "Not listed on any RBL",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
},
@ -290,35 +290,39 @@ func TestGetBlacklistScore(t *testing.T) {
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
RelevantListedCount: 1,
},
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational)
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
RelevantListedCount: 2,
},
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
expectedScore: 84, // 100 - 2*100/12 = 84
},
{
name: "Listed on 3 RBLs",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 3,
RelevantListedCount: 3,
},
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
expectedScore: 75, // 100 - 3*100/12 = 75
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
RelevantListedCount: 4,
},
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
expectedScore: 67, // 100 - 4*100/12 = 67
},
}
@ -326,7 +330,7 @@ func TestGetBlacklistScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := checker.CalculateScore(tt.results)
score, _ := checker.CalculateScore(tt.results, false)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@ -335,8 +339,8 @@ func TestGetBlacklistScore(t *testing.T) {
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &RBLResults{
Checks: map[string][]api.BlacklistCheck{
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},
@ -363,8 +367,8 @@ func TestGetUniqueListedIPs(t *testing.T) {
}
func TestGetRBLsForIP(t *testing.T) {
results := &RBLResults{
Checks: map[string][]api.BlacklistCheck{
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},

View file

@ -24,7 +24,7 @@ package analyzer
import (
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
@ -43,16 +43,18 @@ type ReportGenerator struct {
// NewReportGenerator creates a new report generator
func NewReportGenerator(
receiverHostname string,
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
dnswls []string,
checkAllIPs bool,
rspamdAPIURL string,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(),
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
@ -64,14 +66,14 @@ func NewReportGenerator(
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
Authentication *model.AuthenticationResults
Content *ContentResults
DNS *api.DNSResults
Headers *api.HeaderAnalysis
DNS *model.DNSResults
Headers *model.HeaderAnalysis
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
SpamAssassin *model.SpamAssassinResult
Rspamd *model.RspamdResult
}
// AnalyzeEmail performs complete email analysis
@ -83,7 +85,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
// Fall back to the received chain's inbound TLS when no x-tls header was present.
if results.Authentication != nil && results.Headers != nil {
r.authAnalyzer.ReconcileXTLS(results.Authentication, results.Headers.ReceivedChain)
}
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
@ -94,11 +100,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
}
// GenerateReport creates a complete API report from analysis results
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
reportID := uuid.New()
now := time.Now()
report := &api.Report{
report := &model.Report{
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
CreatedAt: now,
@ -139,8 +145,10 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
blacklistScore := 0
var blacklistGrade string
var whitelistGrade string
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
}
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
@ -165,19 +173,19 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
spamGrade = MinGrade(saGrade, rspamdGrade)
}
report.Summary = &api.ScoreSummary{
report.Summary = &model.ScoreSummary{
DnsScore: dnsScore,
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore,
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore,
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
ContentScore: contentScore,
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore,
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore,
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
}
// Add authentication results
@ -209,16 +217,16 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
// Add SpamAssassin result with individual deliverability score
if results.SpamAssassin != nil {
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
saGradeTyped := model.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
}
report.Spamassassin = results.SpamAssassin
// Add rspamd result with individual deliverability score
if results.Rspamd != nil {
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
}
report.Rspamd = results.Rspamd
@ -284,7 +292,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
}
if minusGrade < 255 {
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade}))
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@ import (
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
// Default rspamd action thresholds (rspamd built-in defaults)
@ -37,27 +37,38 @@ const (
)
// RspamdAnalyzer analyzes rspamd results from email headers
type RspamdAnalyzer struct{}
type RspamdAnalyzer struct {
symbols map[string]string
}
// NewRspamdAnalyzer creates a new rspamd analyzer
func NewRspamdAnalyzer() *RspamdAnalyzer {
return &RspamdAnalyzer{}
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
return &RspamdAnalyzer{symbols: symbols}
}
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
headers := email.GetRspamdHeaders()
if len(headers) == 0 {
return nil
}
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
// 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 := &model.RspamdResult{
Symbols: make(map[string]model.SpamTestDetail),
}
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
result.Report = &report
a.parseSpamdResult(spamdResult, result)
}
@ -74,6 +85,16 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
result.Server = &server
}
// Populate symbol descriptions from the lookup map
if a.symbols != nil {
for name, sym := range result.Symbols {
if desc, ok := a.symbols[name]; ok {
sym.Description = &desc
result.Symbols[name] = sym
}
}
}
// Derive IsSpam from score vs reject threshold.
if result.Threshold > 0 {
result.IsSpam = result.Score >= result.Threshold
@ -86,7 +107,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
// parseSpamdResult parses the X-Spamd-Result header
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) {
// Extract score and threshold from the first line
// e.g. "default: False [-3.91 / 15.00]"
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
@ -111,15 +132,16 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
}
// Parse symbols: SYMBOL(score)[params]
// Each symbol entry is separated by ";"
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`)
// Each symbol entry is separated by ";", so within each part we use a
// greedy match to capture params that may contain nested brackets.
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
for _, part := range strings.Split(header, ";") {
part = strings.TrimSpace(part)
matches := symbolRe.FindStringSubmatch(part)
if len(matches) > 2 {
name := matches[1]
score, _ := strconv.ParseFloat(matches[2], 64)
sym := api.RspamdSymbol{
sym := model.SpamTestDetail{
Name: name,
Score: float32(score),
}
@ -133,7 +155,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
}
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) {
if result == nil {
return 100, "" // rspamd not installed
}

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/model"
)
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 := &model.RspamdResult{
Symbols: make(map[string]model.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 *model.RspamdResult
expectedScore int
expectedGrade string
}{
{
name: "Nil result (rspamd not installed)",
result: nil,
expectedScore: 100,
expectedGrade: "",
},
{
name: "Score well below threshold",
result: &model.RspamdResult{
Score: -3.91,
Threshold: 15.00,
},
expectedScore: 100,
expectedGrade: "A+",
},
{
name: "Score at zero",
result: &model.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: &model.RspamdResult{
Score: 15.00,
Threshold: 15.00,
},
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
expectedScore: 50,
},
{
name: "Score above 2*threshold",
result: &model.RspamdResult{
Score: 31.00,
Threshold: 15.00,
},
expectedScore: 0,
expectedGrade: "F",
},
{
name: "Score exactly at 2*threshold",
result: &model.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

@ -22,7 +22,7 @@
package analyzer
import (
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
)
// ScoreToGrade converts a percentage score (0-100) to a letter grade
@ -65,14 +65,16 @@ func ScoreToGradeKind(score int) string {
}
}
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
func ScoreToReportGrade(score int) api.ReportGrade {
return api.ReportGrade(ScoreToGrade(score))
// ScoreToReportGrade converts a percentage score to an model.ReportGrade
func ScoreToReportGrade(score int) model.ReportGrade {
return model.ReportGrade(ScoreToGrade(score))
}
// gradeRank returns a numeric rank for a grade (lower = worse)
func gradeRank(grade string) int {
switch grade {
case "A++":
return 7
case "A+":
return 6
case "A":

View file

@ -27,7 +27,8 @@ import (
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
@ -39,14 +40,22 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
}
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
// 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 := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
}
// Parse X-Spam-Status header
@ -68,13 +77,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
a.parseSpamReport(reportHeader, result)
}
// Parse X-Spam-Checker-Version
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
result.Version = api.PtrTo(strings.TrimSpace(versionHeader))
result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
}
return result
@ -82,7 +91,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
// parseSpamStatus parses the X-Spam-Status header
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) {
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
@ -126,7 +135,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
// * 0.0 TEST_NAME Description line 1
// * continuation line 2
// * continuation line 3
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
segments := strings.Split(report, "*")
// Regex to match test lines: score TEST_NAME Description
@ -148,7 +157,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
// Save previous test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := api.SpamTestDetail{
detail := model.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
@ -166,7 +175,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
currentDescription.WriteString(description)
// Initialize with score
result.TestDetails[testName] = api.SpamTestDetail{
result.TestDetails[testName] = model.SpamTestDetail{
Name: testName,
Score: float32(score),
}
@ -183,7 +192,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
// Save the last test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := api.SpamTestDetail{
detail := model.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
@ -193,7 +202,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
}
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
if result == nil {
return 100, "" // No spam scan results, assume good
}

View file

@ -27,7 +27,8 @@ import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseSpamStatus(t *testing.T) {
@ -77,8 +78,8 @@ func TestParseSpamStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@ -115,27 +116,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
expectedTests := map[string]api.SpamTestDetail{
expectedTests := map[string]model.SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: api.PtrTo("From address doesn't match envelope sender"),
Description: utils.PtrTo("From address doesn't match envelope sender"),
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
Description: api.PtrTo("All mail servers are trusted"),
Description: utils.PtrTo("All mail servers are trusted"),
},
}
@ -157,7 +158,7 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *api.SpamAssassinResult
result *model.SpamAssassinResult
expectedScore int
minScore int
maxScore int
@ -169,7 +170,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Excellent score (negative)",
result: &api.SpamAssassinResult{
result: &model.SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
@ -177,7 +178,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Good score (below threshold)",
result: &api.SpamAssassinResult{
result: &model.SpamAssassinResult{
Score: 2.0,
RequiredScore: 5.0,
},
@ -185,7 +186,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Score at threshold",
result: &api.SpamAssassinResult{
result: &model.SpamAssassinResult{
Score: 5.0,
RequiredScore: 5.0,
},
@ -193,7 +194,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Above threshold (spam)",
result: &api.SpamAssassinResult{
result: &model.SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
@ -201,7 +202,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "High spam score",
result: &api.SpamAssassinResult{
result: &model.SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
@ -209,7 +210,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Very high spam score",
result: &api.SpamAssassinResult{
result: &model.SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},

2617
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -70,6 +70,10 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig["custom_logo_url"] = cfg.CustomLogoURL
}
if !cfg.DisableTestList {
appConfig["test_list_enabled"] = true
}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
@ -95,6 +99,7 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
router.GET("/domain/:domain", serveOrReverse("/", cfg))
router.GET("/test/", serveOrReverse("/", cfg))
router.GET("/test/:testid", serveOrReverse("/", cfg))
router.GET("/history/", serveOrReverse("/", cfg))
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", serveOrReverse("", cfg))

View file

@ -13,6 +13,12 @@
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 {
switch (result) {
case "pass":
@ -97,6 +103,28 @@
</span>
</h4>
</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">
<!-- IPREV -->
{#if authentication.iprev}
@ -142,6 +170,88 @@
</div>
{/if}
<!-- X-Ptr (HELO / reverse DNS consistency) -->
{#if authentication.x_ptr}
<div class="list-group-item" id="authentication-x-ptr">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.x_ptr.result,
true,
)} {getAuthResultClass(authentication.x_ptr.result, true)} me-2 fs-5"
></i>
<div>
<strong>HELO / PTR</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Checks that the HELO/EHLO hostname announced by the sending server matches the sender IP's reverse DNS (PTR) record."
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_ptr.result,
true,
)}"
>
{authentication.x_ptr.result}
</span>
{#if authentication.x_ptr.helo}
<div class="small">
<strong>Announced HELO:</strong>
<span class="text-muted">{authentication.x_ptr.helo}</span>
</div>
{/if}
{#if authentication.x_ptr.ptr}
<div class="small">
<strong>Reverse DNS (PTR):</strong>
<span class="text-muted">{authentication.x_ptr.ptr}</span>
</div>
{/if}
{#if authentication.x_ptr.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.x_ptr.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- X-TLS (Transport encryption) -->
{#if authentication.x_tls}
<div class="list-group-item" id="authentication-x-tls">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.x_tls.result,
true,
)} {getAuthResultClass(authentication.x_tls.result, true)} me-2 fs-5"
></i>
<div>
<strong>Transport TLS</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Whether the inbound connection that delivered this message used TLS encryption (x-tls). Falls back to the inbound Received hop when no x-tls header is present."
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_tls.result,
true,
)}"
>
{authentication.x_tls.result}
</span>
{#if authentication.x_tls.details}
<div class="small text-muted mt-1">
{authentication.x_tls.details}
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- SPF (Required) -->
<div class="list-group-item">
<div class="d-flex align-items-start" id="authentication-spf">

View file

@ -1,11 +1,16 @@
<script lang="ts">
import type { BimiRecord } from "$lib/api/types.gen";
import type { BimiRecord, DmarcRecord } from "$lib/api/types.gen";
interface Props {
bimiRecord?: BimiRecord;
dmarcRecord?: DmarcRecord;
}
let { bimiRecord }: Props = $props();
let { bimiRecord, dmarcRecord }: Props = $props();
const dmarcEnforced = $derived(
dmarcRecord?.policy === "quarantine" || dmarcRecord?.policy === "reject",
);
</script>
{#if bimiRecord}
@ -72,6 +77,26 @@
{bimiRecord.error}
</div>
{/if}
{#if !bimiRecord.valid && dmarcEnforced}
<div class="alert alert-info mt-3 mb-0">
<h6 class="alert-heading">
<i class="bi bi-lightbulb me-1"></i>
Explicitly decline BIMI participation
</h6>
<p class="mb-2 small">
If you do not intend to publish a brand logo, you can add a declination
record to signal that this domain deliberately opts out of BIMI. This
prevents mail clients from falling back to a parent-domain record:
</p>
<code class="d-block bg-white rounded p-2 text-break border"
>{bimiRecord.selector}._bimi.{bimiRecord.domain}. IN TXT "v=BIMI1; l=; a="</code
>
<p class="mt-1 mb-0 small text-muted">
Declination record format as defined in §&thinsp;4.3.1 of
<em>draft-brand-indicators-for-message-identification</em>.
</p>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,23 +1,21 @@
<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 { theme } from "$lib/stores/theme";
import EmailPathCard from "./EmailPathCard.svelte";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
blacklists: Record<string, BlacklistCheck[]>;
blacklistGrade?: string;
blacklistScore?: number;
receivedChain?: ReceivedHop[];
}
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
</script>
<div class="card shadow-sm" id="rbl-details">
<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>
<i class="bi bi-shield-exclamation me-2"></i>
Blacklist Checks
@ -35,11 +33,7 @@
</h4>
</div>
<div class="card-body">
{#if receivedChain}
<EmailPathCard {receivedChain} />
{/if}
<div class="row row-cols-1 row-cols-lg-2">
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
{#each Object.entries(blacklists) as [ip, checks]}
<div class="col mb-3">
<h5 class="text-muted">

View file

@ -3,15 +3,31 @@
interface Props {
dmarcRecord?: DmarcRecord;
fromDomain?: string;
}
let { dmarcRecord }: Props = $props();
let { dmarcRecord, fromDomain }: Props = $props();
const isFallback = $derived(
!!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain,
);
// A single-label domain (no dot) is a TLD/PSD level fallback
const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes("."));
// Helper function to determine policy strength
const policyStrength = (policy: string | undefined): number => {
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
return strength[policy || "none"] || 0;
};
// Effective policy after applying DMARCbis t=y downgrade
const effectivePolicy = $derived((): string => {
const p = dmarcRecord?.policy ?? "none";
if (!dmarcRecord?.test_mode) return p;
if (p === "reject") return "quarantine";
if (p === "quarantine") return "none";
return p;
});
</script>
{#if dmarcRecord}
@ -52,6 +68,27 @@
{/if}
</div>
<!-- Fallback domain notice -->
{#if isFallback}
<div class="mb-3">
<strong>Record found at:</strong>
<code>{dmarcRecord.domain}</code>
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
No DMARC record exists for <code>{fromDomain}</code>. The record above was
inherited from
{#if isPsdFallback}
the Public Suffix Domain <code>{dmarcRecord.domain}</code> via the DMARCbis
DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment).
{:else}
the organizational domain <code>{dmarcRecord.domain}</code> via the
DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain
fallback).
{/if}
</div>
</div>
{/if}
<!-- Policy -->
{#if dmarcRecord.policy}
<div class="mb-3">
@ -99,6 +136,53 @@
</div>
{/if}
<!-- Test Mode (DMARCbis t= tag) -->
{#if dmarcRecord.test_mode}
<div class="mb-3">
<strong>Test Mode:</strong>
<span class="badge bg-warning">t=y (active)</span>
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-flask me-1"></i>
<strong>Test mode active</strong> — DMARCbis-compliant receivers will
downgrade the effective policy one level:
{#if dmarcRecord.policy === "reject"}
<code>p=reject</code> is applied as <code>p=quarantine</code>.
{:else if dmarcRecord.policy === "quarantine"}
<code>p=quarantine</code> is applied as <code>p=none</code> (no action taken).
{:else}
<code>p=none</code> is unaffected by test mode.
{/if}
Aggregate reports are still generated normally.
This tag replaces the deprecated <code>pct=</code> for gradual rollout.
</div>
</div>
{/if}
<!-- PSD tag (DMARCbis psd=) -->
{#if dmarcRecord.psd === "y"}
<div class="mb-3">
<strong>Public Suffix Domain:</strong>
<span class="badge bg-info">psd=y</span>
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
<strong>PSD declared</strong> — this domain is declared as a Public Suffix
Domain. DMARCbis-compliant receivers will apply this policy to subdomains
that have no DMARC record of their own when using the DNS Tree Walk algorithm.
</div>
</div>
{:else if dmarcRecord.psd === "n"}
<div class="mb-3">
<strong>Organizational Domain Boundary:</strong>
<span class="badge bg-info">psd=n</span>
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
<strong>Org Domain declared</strong><code>psd=n</code> explicitly declares
this as an Organizational Domain boundary. Subdomains with separate DNS
delegation will use their own independent DMARCbis Tree Walk.
</div>
</div>
{/if}
<!-- Subdomain Policy -->
{#if dmarcRecord.subdomain_policy}
{@const mainStrength = policyStrength(dmarcRecord.policy)}
@ -142,7 +226,43 @@
</div>
{/if}
<!-- Percentage -->
<!-- Non-Existent Subdomain Policy (np tag, DMARCbis) -->
{#if dmarcRecord.nonexistent_subdomain_policy}
{@const effectiveSubStrength = policyStrength(dmarcRecord.subdomain_policy ?? dmarcRecord.policy)}
{@const npStrength = policyStrength(dmarcRecord.nonexistent_subdomain_policy)}
<div class="mb-3">
<strong>Non-Existent Subdomain Policy:</strong>
<span
class="badge {dmarcRecord.nonexistent_subdomain_policy === 'reject'
? 'bg-success'
: dmarcRecord.nonexistent_subdomain_policy === 'quarantine'
? 'bg-warning'
: 'bg-secondary'}"
>
{dmarcRecord.nonexistent_subdomain_policy}
</span>
{#if npStrength >= effectiveSubStrength}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Good configuration</strong> — non-existent subdomain policy is equal to or stricter
than the effective subdomain policy.
</div>
{:else}
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Weaker protection for non-existent subdomains</strong> — consider setting
<code>np={dmarcRecord.subdomain_policy ?? dmarcRecord.policy}</code> to match your subdomain policy.
</div>
{/if}
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
The <code>np=</code> tag is introduced by <strong>DMARCbis</strong> (draft-ietf-dmarc-dmarcbis),
a draft RFC updating RFC 7489. Support may vary across mail receivers.
</div>
</div>
{/if}
<!-- Percentage (pct=, deprecated in DMARCbis) -->
{#if dmarcRecord.percentage !== undefined}
<div class="mb-3">
<strong>Enforcement Percentage:</strong>
@ -155,25 +275,35 @@
>
{dmarcRecord.percentage}%
</span>
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Deprecated tag</strong> — the <code>pct=</code> tag is removed in
DMARCbis. Many receivers already ignore it. For gradual rollout, replace it
with <code>t=y</code> (test mode); for full enforcement, simply remove
<code>pct=</code> from your record.
{#if dmarcRecord.percentage === 0}
<br /><strong>pct=0 is an anti-pattern</strong> — it was widely misused
as a signal to bypass DMARC entirely, which is one reason the tag was
removed. Use <code>t=y</code> instead.
{/if}
</div>
{#if dmarcRecord.percentage === 100}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
This provides maximum protection.
</div>
{:else if dmarcRecord.percentage >= 50}
{:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
messages are subject to DMARC policy. Consider increasing to
<code>pct=100</code> once you've validated your configuration.
messages are subject to DMARC policy. Receivers ignoring pct= will apply
the full policy regardless.
</div>
{:else}
{:else if dmarcRecord.percentage > 0}
<div class="alert alert-danger mt-2 mb-0 small">
<i class="bi bi-x-circle me-1"></i>
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
messages are protected. Gradually increase to <code>pct=100</code> for full
protection.
messages are protected. Receivers ignoring pct= will apply full policy.
</div>
{/if}
</div>
@ -259,6 +389,30 @@
</div>
{/if}
<!-- Deprecated rf=/ri= tags -->
{#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri}
<div class="alert alert-warning mt-2 mb-3 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Deprecated tags detected</strong> — your record contains
{#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri}
<code>rf=</code> and <code>ri=</code> tags that are
{:else if dmarcRecord.deprecated_rf}
the <code>rf=</code> tag that is
{:else}
the <code>ri=</code> tag that is
{/if}
removed in DMARCbis. Modern receivers will ignore
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}.
{#if dmarcRecord.deprecated_ri}
Aggregate reporting interval is now fixed at ≥ 24 hours regardless of
<code>ri=</code>.
{/if}
You can safely remove
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"}
from your DMARC record.
</div>
{/if}
<!-- Error -->
{#if dmarcRecord.error}
<div class="text-danger">

View file

@ -6,9 +6,11 @@
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import GradeDisplay from "./GradeDisplay.svelte";
import HeloPtrMatchDisplay from "./HeloPtrMatchDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import ReturnOkDisplay from "./ReturnOkDisplay.svelte";
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
interface Props {
@ -92,6 +94,16 @@
{senderIp}
/>
<!-- HELO / PTR Consistency -->
<HeloPtrMatchDisplay
heloHostname={dnsResults.helo_hostname ?? receivedChain?.[0]?.from}
ptrRecords={dnsResults.ptr_records}
heloPtrMatch={dnsResults.helo_ptr_match}
/>
<!-- Return Address Reachability (ReturnOK) -->
<ReturnOkDisplay returnOk={dnsResults.return_ok} />
<hr class="my-4" />
<!-- Return-Path Domain Section -->
@ -142,8 +154,7 @@
</h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2">
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
domain
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain
</span>
{/if}
</div>
@ -165,10 +176,13 @@
{/if}
<!-- DMARC Record -->
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
<DmarcRecordDisplay
dmarcRecord={dnsResults.dmarc_record}
fromDomain={dnsResults.from_domain}
/>
<!-- BIMI Record -->
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} dmarcRecord={dnsResults.dmarc_record} />
{/if}
</div>
</div>

View file

@ -1,17 +1,42 @@
<script lang="ts">
import type { ReceivedHop } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
receivedChain: ReceivedHop[];
}
let { receivedChain }: Props = $props();
// Mirror of the backend protocolIndicatesTLS (RFC 3848): the transport keyword
// gains a trailing "S" when TLS was used (ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA...).
function protocolIndicatesTLS(withProto: string | undefined | null): boolean {
if (!withProto) return false;
const p = withProto.trim().toUpperCase();
return p.endsWith("S") || p.endsWith("SA");
}
// RFC 3848: a trailing "A" means the sender authenticated (SMTP AUTH):
// ESMTPA, ESMTPSA, LMTPA, LMTPSA...
function protocolIndicatesAuth(withProto: string | undefined | null): boolean {
if (!withProto) return false;
return withProto.trim().toUpperCase().endsWith("A");
}
</script>
{#if receivedChain && receivedChain.length > 0}
<div class="mb-3" id="email-path">
<h5>Email Path (Received Chain)</h5>
<div class="list-group">
<div class="card shadow-sm" id="email-path">
<div
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}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
@ -30,7 +55,7 @@
: "-"}
</small>
</div>
{#if hop.with || hop.id}
{#if hop.with || hop.id || hop.from}
<p class="mb-1 small d-flex gap-3">
{#if hop.with}
<span>
@ -50,6 +75,63 @@
{/if}
</p>
{/if}
<p class="mb-0 small d-flex flex-wrap align-items-center gap-3">
{#if hop.tls}
<span class="badge bg-success">
<i class="bi bi-lock-fill me-1"></i>TLS
</span>
{#if hop.tls.version}
<span>
<span class="text-muted">Version:</span>
<code>{hop.tls.version}</code>
</span>
{/if}
{#if hop.tls.cipher}
<span>
<span class="text-muted">Cipher:</span>
<code>{hop.tls.cipher}</code>
</span>
{/if}
{#if hop.tls.bits}
<span>
<span class="text-muted">Strength:</span>
<code>{hop.tls.bits} bits</code>
</span>
{/if}
{#if hop.tls.verified !== undefined}
<span
class:text-success={hop.tls.verified}
class:text-warning={!hop.tls.verified}
>
<i
class="bi {hop.tls.verified
? 'bi-patch-check-fill'
: 'bi-patch-exclamation-fill'} me-1"
></i>
{hop.tls.verified
? "Certificate trusted"
: "Certificate not trusted"}
</span>
{/if}
{:else if protocolIndicatesTLS(hop.with)}
<span class="badge bg-success">
<i class="bi bi-lock-fill me-1"></i>TLS
</span>
{:else if hop.with}
<span class="badge bg-secondary">
<i class="bi bi-unlock me-1"></i>No TLS
</span>
{:else}
<span class="badge bg-light text-muted border">
<i class="bi bi-question-circle me-1"></i>TLS unknown
</span>
{/if}
{#if protocolIndicatesAuth(hop.with)}
<span class="badge bg-info">
<i class="bi bi-person-check-fill me-1"></i>Authenticated
</span>
{/if}
</p>
</div>
{/each}
</div>

View file

@ -11,7 +11,7 @@
headerScore?: number;
}
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
</script>
<div class="card shadow-sm" id="header-details">

View file

@ -0,0 +1,87 @@
<script lang="ts">
interface Props {
heloHostname?: string;
ptrRecords?: string[];
heloPtrMatch?: boolean;
}
let { heloHostname, ptrRecords, heloPtrMatch }: Props = $props();
const normalize = (host: string) => host.replace(/\.$/, "").trim().toLowerCase();
// Local comparison, identical to the per-record badge logic below, so the
// summary alert can never contradict the individual "Match" badges.
const localMatch = $derived(
!!heloHostname &&
!!ptrRecords &&
ptrRecords.some((ptr) => normalize(heloHostname) === normalize(ptr)),
);
// Prefer the backend verdict when it is present; otherwise fall back to the
// local comparison (e.g. for results produced before helo_ptr_match existed).
const isMatch = $derived(heloPtrMatch ?? localMatch);
</script>
{#if heloHostname}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={isMatch}
class:text-success={isMatch}
class:bi-x-circle-fill={!isMatch}
class:text-danger={!isMatch}
></i>
HELO / PTR Consistency
</h5>
<span class="badge bg-secondary">HELO</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
The HELO/EHLO hostname is the name the sending server announces when it connects.
Many mail servers check that this name matches the sender IP's reverse DNS (PTR)
record. A mismatch is a common spam signal and can hurt deliverability.
</p>
<div class="mt-2">
<strong>Announced HELO:</strong> <code>{heloHostname}</code>
</div>
{#if ptrRecords && ptrRecords.length > 0}
<div class="mt-1">
<strong>PTR Hostname(s):</strong>
{#each ptrRecords as ptr}
<div class="d-flex gap-2 align-items-center mt-1">
{#if normalize(heloHostname) === normalize(ptr)}
<span class="badge bg-success">Match</span>
{:else}
<span class="badge bg-secondary">Different</span>
{/if}
<code>{ptr}</code>
</div>
{/each}
</div>
{/if}
</div>
{#if !isMatch}
<div class="list-group list-group-flush">
<div class="list-group-item">
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> The announced HELO hostname
<code>{heloHostname}</code>
{#if ptrRecords && ptrRecords.length > 0}
does not match the sender's PTR record{ptrRecords.length > 1 ? "s" : ""}
({#each ptrRecords as ptr, i}<code>{ptr}</code>{i <
ptrRecords.length - 1
? ", "
: ""}{/each}).
{:else}
could not be matched against a PTR record.
{/if}
Configuring the HELO name to match reverse DNS improves deliverability.
</div>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { TestSummary } from "$lib/api/types.gen";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
tests: TestSummary[];
}
let { tests }: Props = $props();
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div class="table-responsive shadow-sm">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th class="ps-4" style="width: 80px;">Grade</th>
<th style="width: 80px;">Score</th>
<th>Domain</th>
<th>Date</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
{#each tests as test}
<tr class="cursor-pointer" onclick={() => goto(`/test/${test.test_id}`)}>
<td class="ps-4">
<GradeDisplay grade={test.grade} size="small" />
</td>
<td>
<span class="badge bg-secondary">{test.score}%</span>
</td>
<td>
{#if test.from_domain}
<code>{test.from_domain}</code>
{:else}
<span class="text-muted">-</span>
{/if}
</td>
<td class="text-muted">
{formatDate(test.created_at)}
</td>
<td>
<i class="bi bi-chevron-right text-muted"></i>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
.cursor-pointer:hover td {
background-color: var(--bs-tertiary-bg);
}
</style>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import type { SchemasReturnOk, SchemasReturnOkDomain } from "$lib/api/types.gen";
interface Props {
returnOk?: SchemasReturnOk;
}
let { returnOk }: Props = $props();
type Row = { label: string; entry: SchemasReturnOkDomain };
const rows = $derived<Row[]>(
[
returnOk?.from ? { label: "From", entry: returnOk.from } : undefined,
returnOk?.return_path
? { label: "Return-Path", entry: returnOk.return_path }
: undefined,
].filter((r): r is Row => r !== undefined),
);
const hasFail = $derived(rows.some((r) => r.entry.status === "fail"));
const hasWarn = $derived(rows.some((r) => r.entry.status === "warn"));
const allPass = $derived(rows.length > 0 && rows.every((r) => r.entry.status === "pass"));
// Header icon reflects the worst status across the checked domains.
const headerOk = $derived(allPass);
function badgeClass(status: string): string {
if (status === "pass") return "bg-success";
if (status === "warn") return "bg-warning text-dark";
return "bg-danger";
}
function badgeLabel(status: string): string {
if (status === "pass") return "MX";
if (status === "warn") return "A/AAAA only";
return "Unreachable";
}
</script>
{#if rows.length > 0}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={headerOk}
class:text-success={headerOk}
class:bi-exclamation-triangle-fill={!headerOk && !hasFail}
class:text-warning={!headerOk && !hasFail}
class:bi-x-circle-fill={hasFail}
class:text-danger={hasFail}
></i>
Return Address Reachability
</h5>
<span class="badge bg-secondary">RETURN-OK</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
Replies (to the From address) and bounces (to the Return-Path) can only be delivered
if the sender's domains accept mail. A domain should publish MX records; an A/AAAA
record works as an implicit fallback but is not recommended. A domain with neither
is unreachable and silently drops replies and bounces.
</p>
</div>
<div class="list-group list-group-flush">
{#each rows as { label, entry } (label)}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="text-muted" style="min-width: 6.5rem">{label} domain:</span>
<code>{entry.domain}</code>
<span class="badge {badgeClass(entry.status)}">
{badgeLabel(entry.status)}
</span>
{#if entry.org_domain}
<small class="text-muted">
via organizational domain <code>{entry.org_domain}</code>
</small>
{/if}
</div>
</div>
{/each}
</div>
{#if hasFail || hasWarn}
<div class="list-group list-group-flush">
<div class="list-group-item">
{#if hasFail}
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle me-1"></i>
<strong>Error:</strong> At least one sender domain has no MX and no A/AAAA record.
Replies or bounce messages to that domain will be lost. Publish an MX record pointing
to a mail server that accepts mail.
</div>
{:else if hasWarn}
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> A sender domain has no MX record and relies on its A/AAAA
record (implicit MX). Mail is still deliverable, but publishing an explicit MX
record is recommended.
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}

View file

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

View file

@ -25,16 +25,32 @@
// Email sender information
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
const hasDkim =
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: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
highlight: {
color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
bold: true,
},
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
});
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: " email from " });
}
segments.push({ text: " from " });
segments.push({
text: mailFrom,
highlight: { emphasis: true },

View file

@ -11,7 +11,7 @@
<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 justify-content-between align-items-center">
<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
@ -25,7 +25,7 @@
no impact on the overall score.
</p>
<div class="row row-cols-1 row-cols-lg-2">
<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">

View file

@ -23,5 +23,6 @@ export { default as RspamdCard } from "./RspamdCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
export { default as SummaryCard } from "./SummaryCard.svelte";
export { default as HistoryTable } from "./HistoryTable.svelte";
export { default as TinySurvey } from "./TinySurvey.svelte";
export { default as WhitelistCard } from "./WhitelistCard.svelte";

View file

@ -26,6 +26,7 @@ interface AppConfig {
survey_url?: string;
custom_logo_url?: string;
rbls?: string[];
test_list_enabled?: boolean;
}
const defaultConfig: AppConfig = {

View file

@ -40,7 +40,17 @@
<Logo color={$theme === "light" ? "black" : "white"} />
{/if}
</a>
<div>
{#if $appConfig.test_list_enabled}
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/history/">
<i class="bi bi-clock-history me-1"></i>
History
</a>
</li>
</ul>
{/if}
<div class="d-flex align-items-center">
<span class="d-none d-md-inline navbar-text text-primary small">
Open-Source Email Deliverability Tester
</span>

View file

@ -1,12 +1,30 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { createTest as apiCreateTest } from "$lib/api";
import { FeatureCard, HowItWorksStep } from "$lib/components";
import { createTest as apiCreateTest, listTests } from "$lib/api";
import type { TestSummary } from "$lib/api/types.gen";
import { FeatureCard, HowItWorksStep, HistoryTable } from "$lib/components";
import { appConfig } from "$lib/stores/config";
let loading = $state(false);
let error = $state<string | null>(null);
let recentTests = $state<TestSummary[]>([]);
async function loadRecentTests() {
if (!$appConfig.test_list_enabled) return;
try {
const response = await listTests({ query: { offset: 0, limit: 5 } });
if (response.data) {
recentTests = response.data.tests;
}
} catch {
// Silently ignore — this is a non-critical section
}
}
$effect(() => {
loadRecentTests();
});
async function createTest() {
loading = true;
@ -176,6 +194,32 @@
</div>
</section>
<!-- Recently Tested -->
{#if $appConfig.test_list_enabled && recentTests.length > 0}
<section class="py-5 border-bottom border-3" id="recent">
<div class="container py-4">
<div class="row text-center mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="display-5 fw-bold mb-3">Recently Tested</h2>
<p class="text-muted">Latest deliverability reports from this instance</p>
</div>
</div>
<div class="row">
<div class="col-lg-10 mx-auto">
<HistoryTable tests={recentTests} />
<div class="text-center mt-4">
<a href="/history/" class="btn btn-outline-primary">
<i class="bi bi-clock-history me-2"></i>
View All Tests
</a>
</div>
</div>
</div>
</div>
</section>
{/if}
<!-- Features Section -->
<section class="py-5" id="features">
<div class="container py-4">

View file

@ -3,7 +3,7 @@
import { onMount } from "svelte";
import { checkBlacklist } from "$lib/api";
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";
let ip = $derived($page.params.ip);
@ -122,8 +122,8 @@
>
<p class="mb-0 mt-1 small">
This IP address is listed on {result.listed_count} of
{result.checks.length} checked blacklist{result
.checks.length > 1
{result.blacklists.length} checked blacklist{result
.blacklists.length > 1
? "s"
: ""}.
</p>
@ -150,12 +150,23 @@
</div>
</div>
<div class="row">
<!-- Blacklist Results Card -->
<div class="col col-lg-6">
<BlacklistCard
blacklists={{ [result.ip]: result.checks }}
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 -->
<div class="card shadow-sm mt-4">

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { listTests, createTest as apiCreateTest } from "$lib/api";
import type { TestSummary } from "$lib/api/types.gen";
import { HistoryTable } from "$lib/components";
let tests = $state<TestSummary[]>([]);
let total = $state(0);
let offset = $state(0);
let limit = $state(20);
let loading = $state(true);
let error = $state<string | null>(null);
let creatingTest = $state(false);
async function loadTests() {
loading = true;
error = null;
try {
const response = await listTests({ query: { offset, limit } });
if (response.data) {
tests = response.data.tests;
total = response.data.total;
} else if (response.error) {
if (
response.error &&
typeof response.error === "object" &&
"error" in response.error &&
response.error.error === "feature_disabled"
) {
error = "Test listing is disabled on this instance.";
} else {
error = "Failed to load tests.";
}
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to load tests.";
} finally {
loading = false;
}
}
$effect(() => {
loadTests();
});
function goToPage(newOffset: number) {
offset = newOffset;
loadTests();
}
async function createTest() {
creatingTest = true;
try {
const response = await apiCreateTest();
if (response.data) {
goto(`/test/${response.data.id}`);
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to create test";
} finally {
creatingTest = false;
}
}
let totalPages = $derived(Math.ceil(total / limit));
let currentPage = $derived(Math.floor(offset / limit) + 1);
</script>
<svelte:head>
<title>Test History - happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row">
<div class="col-lg-10 mx-auto">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="display-6 fw-bold mb-0">
<i class="bi bi-clock-history me-2"></i>
Test History
</h1>
<button
class="btn btn-primary"
onclick={createTest}
disabled={creatingTest}
>
{#if creatingTest}
<span
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
{:else}
<i class="bi bi-plus-lg me-1"></i>
{/if}
New Test
</button>
</div>
{#if loading}
<div class="text-center py-5">
<div
class="spinner-border text-primary"
role="status"
style="width: 3rem; height: 3rem;"
>
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading tests...</p>
</div>
{:else if error}
<div class="alert alert-warning text-center" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
{:else if tests.length === 0}
<div class="text-center py-5">
<i
class="bi bi-inbox display-1 text-muted mb-3 d-block"
></i>
<h2 class="h4 text-muted mb-3">No tests yet</h2>
<p class="text-muted mb-4">
Send a test email to get your first deliverability
report.
</p>
<button
class="btn btn-primary btn-lg"
onclick={createTest}
disabled={creatingTest}
>
<i class="bi bi-envelope-plus me-2"></i>
Start Your First Test
</button>
</div>
{:else}
<HistoryTable {tests} />
<!-- Pagination -->
{#if totalPages > 1}
<nav class="mt-4 d-flex justify-content-between align-items-center">
<small class="text-muted">
Showing {offset + 1}-{Math.min(
offset + limit,
total,
)} of {total} tests
</small>
<ul class="pagination mb-0">
<li
class="page-item"
class:disabled={currentPage === 1}
>
<button
class="page-link"
onclick={() =>
goToPage(
Math.max(0, offset - limit),
)}
disabled={currentPage === 1}
>
<i class="bi bi-chevron-left"></i>
Previous
</button>
</li>
<li class="page-item disabled">
<span class="page-link">
Page {currentPage} of {totalPages}
</span>
</li>
<li
class="page-item"
class:disabled={currentPage === totalPages}
>
<button
class="page-link"
onclick={() =>
goToPage(offset + limit)}
disabled={currentPage === totalPages}
>
Next
<i class="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
{/if}
{/if}
</div>
</div>
</div>

View file

@ -9,6 +9,7 @@
BlacklistCard,
ContentAnalysisCard,
DnsRecordsCard,
EmailPathCard,
ErrorDisplay,
HeaderAnalysisCard,
PendingState,
@ -294,6 +295,15 @@
</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 -->
{#if report.dns_results}
<div class="row mb-4" id="dns">
@ -329,7 +339,6 @@
{blacklists}
blacklistGrade={report.summary?.blacklist_grade}
blacklistScore={report.summary?.blacklist_score}
receivedChain={report.header_analysis?.received_chain}
/>
{/snippet}
@ -384,12 +393,12 @@
{#if report.spamassassin || report.rspamd}
<div class="row mb-4" id="spam">
{#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} />
</div>
{/if}
{#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} />
</div>
{/if}