Compare commits

..

209 commits

Author SHA1 Message Date
8b6154c183 feat: add whitelist checks to IP blacklist endpoint and rename checks to blacklists
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-09 16:01:54 +07:00
56e6494a75 rbl: parallelize IP checks against blacklists using goroutines 2026-03-09 15:34:38 +07:00
0176c3803d docker: split ENV declarations for readability and remove default RBL list 2026-03-09 15:22:36 +07:00
21e16fd847 rbl: remove SpamRats entries from default RBL list
Those RBLs requires an API key
2026-03-09 14:08:34 +07:00
edfe498b27 Improve responsiveness 2026-03-09 14:08:34 +07:00
27650a3496 feat: add raw report display to rspamd card
Add a collapsible Raw Report section to RspamdCard, storing the raw
X-Spamd-Result header value and displaying it like SpamAssassin's report.
2026-03-09 14:08:34 +07:00
d9b9ea87c6 refactor: extract email path into standalone card component
Move the received chain display out of BlacklistCard into EmailPathCard,
giving it its own card styling and placing it as a dedicated section on
the report page.
2026-03-09 13:09:11 +07:00
bb47bb7c29 fix: handle nested brackets in rspamd symbol params 2026-03-09 12:52:15 +07:00
da93d6d706 Add rspamd tests 2026-03-09 12:47:24 +07:00
2a2bfe46a8 fix: various small fixes and improvements
- Add 'skipped' to authentication result enum in OpenAPI spec
- Fix optional chaining on bimiResult.details check
- Add rbls field to AppConfig interface
- Restrict theme storage to valid 'light'/'dark' values only
- Fix null coalescing for blacklist result data
- Fix survey source to use domain instead of ip
2026-03-09 12:46:30 +07:00
55e9bcd3d0 refactor: handle DNS whitelists
Introduce a single DNSListChecker struct with flags to avoid code
duplication with already existing RBL checker.
2026-03-09 12:46:16 +07:00
28424729a5 rbl: support informational-only RBL entries
Add DefaultInformationalRBLs (UCEPROTECT L2/L3) and track listings
separately via RelevantListedCount so these broader lists are displayed
but excluded from the deliverability score calculation.
2026-03-07 14:24:35 +07:00
3cc39c9c54 rbl: add more RBL providers
Add 8 new RBL providers (SpamRats, PSBL, DroneBL, Mailspike, RBL-DNS
and NSZones).
2026-03-07 14:23:51 +07:00
f9c5c815d1 spamassassin: disable Validity network rules scoring
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 12:23:17 +07:00
4245f93ce4 Add MIME-Version recommended header check
Validate MIME-Version header value equals "1.0" and subtract 5 points
from the score if the header is present but invalid. Absence is not
penalized.
2026-03-07 12:14:53 +07:00
9679b381c7 fix: mark Message-ID as invalid when multiple headers are present
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 12:05:38 +07:00
7b9c45fb68 summary: color SPF error in red
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-07 11:42:28 +07:00
b619ebf8c3 Display permerror (SPF test) as error: text-danger
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-07 11:38:09 +07:00
a146940a65 Improve FCrDNS UI: hide non-matching IPs when match exists
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Closes: https://github.com/happyDomain/happydeliver/issues/4
2026-02-23 04:25:48 +07:00
e811d02b3b Add rspamd as a second spam filter alongside SpamAssassin
Some checks are pending
continuous-integration/drone/push Build is running
Closes: #36
2026-02-23 04:01:10 +07:00
8fda7746a1 Add one-click unsubscribe detection and warning
All checks were successful
continuous-integration/drone/push Build is passing
Detect the List-Unsubscribe-Post: List-Unsubscribe=One-Click header
(RFC 8058) and expose it as the 'one-click' unsubscribe method in the
content analysis. When unsubscribe methods are present but one-click is
absent, the summary card now shows a warning nudging senders to adopt it.
2026-02-23 00:15:17 +07:00
96e83ff70d Add multilingual unsubscribe keywords for link detection
The list comes from github.com/knadh/listmonk i18n strings

Bug: https://github.com/happyDomain/happydeliver/issues/8
2026-02-23 00:15:17 +07:00
6b983f0506 Use List-Unsubscribe header URLs for unsubscribe link detection
Bug: https://github.com/happyDomain/happydeliver/issues/8
2026-02-23 00:15:17 +07:00
c50e18a347 Use modern Go slices.Contains and switch instead of if/else if 2026-02-23 00:15:17 +07:00
054cd8ae25 chore(deps): update module golang.org/x/net to v0.50.0 2026-02-23 00:15:17 +07:00
c2917f8580 chore(deps): lock file maintenance
Some checks are pending
continuous-integration/drone/push Build is pending
2026-02-02 01:14:33 +00:00
b39a9dc625 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-26 01:14:47 +00:00
88553cd3c8 Moved perl-net-idn-encode from testing to community
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-01-25 09:52:53 +08:00
8a10eef2f5 Add custom logo URL configuration option
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
Bug: https://github.com/happyDomain/happydeliver/issues/6
2026-01-24 21:42:42 +08:00
64ba6932f7 Use io instead of deprecated ioutil 2026-01-24 21:36:32 +08:00
5453c09420 Use slimmer footer by default
Bug: https://github.com/happyDomain/happydeliver/issues/6
2026-01-24 21:29:09 +08:00
6b4ca126b0 Add colors to css 2026-01-24 21:23:40 +08:00
ac9b567025 web: Format code files 2026-01-24 19:18:26 +08:00
035e864de4 Update go modules 2026-01-24 18:47:48 +08:00
a6efd7710e chore(deps): update module github.com/quic-go/quic-go to v0.57.0 [security]
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-24 08:47:19 +00:00
e6746a1382 chore(deps): update module golang.org/x/net to v0.49.0
Some checks are pending
continuous-integration/drone/push Build is running
2026-01-24 08:47:04 +00:00
d1e48b9885 chore(deps): lock file maintenance
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-05 01:15:53 +00:00
9ac3e165fa Readd missing go dep
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-03 12:18:07 +07:00
dc21b72f52 chore(deps): lock file maintenance
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-02 12:58:55 +00:00
1ba35c6f9f chore(deps): update dependency globals to v17
Some checks are pending
continuous-integration/drone/push Build is running
2026-01-01 19:16:13 +00:00
0fda0f88c1 chore(deps): update dependency @eslint/compat to v2
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-26 06:18:56 +00:00
57a3774d28 chore(deps): update module golang.org/x/net to v0.48.0
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-26 02:24:48 +00:00
11d46de033 chore(deps): update dependency prettier-plugin-svelte to v3.4.1
Some checks are pending
continuous-integration/drone/push Build is running
2025-12-26 02:24:25 +00:00
6081e486bf chore(deps): update dependency svelte-check to v4.3.5
Some checks are pending
continuous-integration/drone/push Build is pending
2025-12-26 02:24:24 +00:00
528a65ca04 chore(deps): lock file maintenance
Some checks are pending
continuous-integration/drone/push Build is running
2025-12-08 01:15:59 +00:00
926796b79e chore(deps): lock file maintenance
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 05:15:50 +00:00
5d02070100 chore(deps): update dependency prettier to v3.7.3
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 03:58:31 +00:00
5701070cc1 chore(deps): update dependency vite to v7.2.6
Some checks are pending
continuous-integration/drone/push Build is pending
2025-12-01 07:14:52 +00:00
954cbe29fc chore(deps): update module golang.org/x/crypto to v0.45.0 [security]
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-28 21:15:11 +00:00
ca2ac3df7c chore(deps): update dependency prettier to v3.7.2
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-28 21:15:01 +00:00
016ed7180e Simplify docker usage, HOSTNAME variable is taken from container hostname
All checks were successful
continuous-integration/drone/push Build is passing
Bug: https://github.com/happyDomain/happydeliver/issues/3
2025-11-23 19:42:20 +07:00
3e76692448 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-18 07:38:17 +00:00
e23afcc77c Add container options to use certificates in postfix
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-18 14:37:39 +07:00
d81ff1731c Fix tests 2025-11-17 10:31:04 +07:00
eef6480e75 Refactor DNS resolution: create an interface to have multiple implementations 2025-11-17 10:15:55 +07:00
f2261adb54 Update go dependancies 2025-11-17 10:15:11 +07:00
3bcbb5814d chore(deps): lock file maintenance
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-14 13:12:20 +00:00
5ac0e2a8bf chore(deps): update dependency vite to v7.2.2
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-14 12:56:23 +00:00
a1e8dd35bd Update dependency @types/node to v24.9.2
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-14 12:56:11 +00:00
e194fcc5b1 Fix calculateTextPlainConsistency algorithm 2025-11-14 12:56:11 +00:00
c19f545df0 chore(deps): update dependency typescript-eslint to v8.46.4
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-14 12:56:00 +00:00
03b58b6f19 Update module github.com/oapi-codegen/oapi-codegen/v2 to v2.5.1
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-14 12:55:50 +00:00
a3ca8ffb48 Fix calculateTextPlainConsistency algorithm 2025-11-14 12:55:50 +00:00
27d5220687 Update dependency globals to v16.5.0
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-14 12:55:22 +00:00
723bec622a Fix calculateTextPlainConsistency algorithm 2025-11-14 12:55:22 +00:00
ee9fa59dbc Update eslint monorepo to v9.39.0
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-14 12:55:16 +00:00
e05c6d0bc2 Fix calculateTextPlainConsistency algorithm 2025-11-14 12:55:16 +00:00
04d8b150b4 chore(deps): update module golang.org/x/net to v0.47.0
Some checks are pending
continuous-integration/drone/push Build is running
2025-11-14 09:11:24 +00:00
e28a96508d Respond with HTTP 200 on blacklist, domain and test pages
All checks were successful
continuous-integration/drone/push Build is passing
Bug: https://github.com/happyDomain/happydeliver/issues/2
2025-11-14 15:34:42 +07:00
ea71074cc8 chore(deps): update dependency svelte-check to v4.3.4
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-13 03:55:11 +00:00
644dfda223 Don't stop polling report if response is not ok
Some checks are pending
continuous-integration/drone/push Build is running
Bug: https://github.com/happyDomain/happydeliver/issues/2
2025-11-13 10:54:50 +07:00
447a666ae7 Fix Domain Alignment align issue when error messages
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-07 17:07:31 +07:00
2172603ad5 content: Add spaces behind each node to reduce gap with plain text
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-11-07 15:14:15 +07:00
c91ab96642 Include the HEALTHCHECK command in Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-07 14:23:29 +07:00
18c8622513 Don't require docker-compose to build the image, use docker hub published 2025-11-07 14:22:58 +07:00
deb9fd4f51 Handle RFC6652
All checks were successful
continuous-integration/drone/push Build is passing
Closes: https://framagit.org/happyDomain/happydeliver/-/issues/1
2025-11-07 14:09:05 +07:00
c52a3aa8a7 Improve DMARC description
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-03 15:00:14 +07:00
5b179e7b93 Domain alignment checks for DKIM 2025-11-03 14:58:48 +07:00
465da6d16a Don't look at original DKIM keys headers 2025-11-03 14:58:23 +07:00
d870fc8130 Add backup/restore commands
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-03 11:53:43 +07:00
1c4eb0653e Don't alert on missing -all on included SPF records
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-01 17:57:57 +07:00
372c9c5153 Handle all options of x-aligned-from 2025-11-01 17:52:28 +07:00
3b301a415f Protonmail is now the best mailbox provider I tested
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-01 15:46:48 +07:00
7231669362 Add survey on RBL report and Domain report page
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-31 11:15:15 +07:00
bc6a6397ad New route to check blacklist only 2025-10-31 11:15:15 +07:00
e166e75e42 Update dependency @eslint/compat to v1.4.1 2025-10-31 11:15:15 +07:00
d3f69630c9 Update dependency eslint-plugin-svelte to v3.13.0 2025-10-31 11:15:15 +07:00
9e9e76cf42 Update dependency @hey-api/openapi-ts to v0.86.10 2025-10-31 11:15:15 +07:00
65c8e9a528 Update Node.js to v24 2025-10-31 11:15:15 +07:00
718b624fb8 Add domain only tests 2025-10-31 11:15:15 +07:00
099965c1f9 Report BIMI issues 2025-10-31 11:06:43 +07:00
90dda126ad Don't consider mailto as suspiscious, search domain alignment 2025-10-30 14:10:42 +07:00
3a8a25ddeb Add info title on non-standard authentication tests 2025-10-30 14:10:42 +07:00
b01ca9b38c Report invalid records in summary 2025-10-30 14:10:42 +07:00
20fe4e5b97 Improve SPF record validation and include error message
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-30 10:34:24 +07:00
706dc6eed9 Include report ID in survey response 2025-10-30 10:31:59 +07:00
164b2a98ab DKIM result can be invalid 2025-10-30 10:31:59 +07:00
dccf75b238 Don't show BIMI hint if result is skipped
Some checks are pending
continuous-integration/drone/push Build is running
2025-10-30 10:06:53 +07:00
f0dbc29da4 Handle multiple dkim authentication-results 2025-10-30 10:03:46 +07:00
8769514f1c Don't deduce point on weak SPF all qualifier, when DMARC is configured
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-28 11:42:23 +07:00
871f4e62f6 Fix content scoring error
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-27 17:56:06 +07:00
86ec7a6100 By default, only check the first IP against RBL, not all chain 2025-10-27 17:56:06 +07:00
2d3316eaaf Lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-27 04:13:14 +00:00
730b43cad1 Fix SurveyURL property name
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-10-27 11:12:48 +07:00
ff5ac0fe1f Create a package to store the version number
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-26 21:54:00 +07:00
b95e5d6732 Add a favicon
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-26 21:42:58 +07:00
0325139461 Add a dark mode 2025-10-26 21:42:58 +07:00
07c7e63ee7 Create a stores directory 2025-10-26 21:42:58 +07:00
faf860f4a1 New option to configure ratelimit 2025-10-26 21:42:58 +07:00
6d2a59dd7b Update dependency @hey-api/openapi-ts to v0.86.4 2025-10-26 21:42:58 +07:00
39185f82bd Update PTR description 2025-10-26 21:42:58 +07:00
dd14d7a814 Update eslint monorepo to v9.38.0 2025-10-26 21:42:58 +07:00
924c54912a Update dependency @types/node to v22.18.12 2025-10-26 21:42:58 +07:00
20513bfc00 Update dependency vite to v7.1.12 2025-10-26 21:42:58 +07:00
b98988c153 Update dependency typescript-eslint to v8.46.2 2025-10-26 21:42:58 +07:00
386951b1eb Update module github.com/getkin/kin-openapi to v0.133.0 2025-10-26 21:42:58 +07:00
1a373d1942 Update dependency @hey-api/openapi-ts to v0.86.3 2025-10-26 21:42:58 +07:00
426fad9856 Add network config 2025-10-26 21:42:58 +07:00
2bedd0ed75 BIMI differenciate declined in summary 2025-10-26 21:42:58 +07:00
cdfdeb74fc Add a sample report in README.md 2025-10-26 21:42:58 +07:00
3c5bd5cbbd Add OpenGraph image and headers 2025-10-26 21:42:58 +07:00
c5bb9e46ac Add a background report sample in hero on home page
Why use a report from icloud.com to illustrate this project?

Among all the common email providers I tested, it achieved the best results.
2025-10-26 21:42:58 +07:00
4c71dd1d53 Handle errors on test page 2025-10-26 21:42:58 +07:00
76de5f60d6 Add survey capability 2025-10-26 21:42:58 +07:00
82373cdaac Redo cli_analyzer 2025-10-26 21:42:58 +07:00
edb172c4bc Update features on home page 2025-10-26 21:42:58 +07:00
53a48cba07 Fix typescript/svelte checks 2025-10-26 21:42:58 +07:00
08c6e0eef2 Rate limit API requests 2025-10-25 03:36:34 +07:00
932bc981b5 Filter Authentication-Results to keep only local ones 2025-10-25 03:36:34 +07:00
b2dc479a79 Update label associated with grade 2025-10-25 03:36:34 +07:00
6e2e403873 Improve pending state with clearer messages 2025-10-25 03:36:34 +07:00
eb28499dfd Monitor testId to update the page on test change 2025-10-25 03:36:25 +07:00
ff1a958220 Add a menu to results page 2025-10-25 03:36:25 +07:00
38b2be58fe Ensure latest created report is fetch 2025-10-25 03:31:29 +07:00
52f43c6bc5 Add x-align-form authentication test 2025-10-25 03:31:29 +07:00
3ea958b2fd Split dns.go in one file per check 2025-10-25 03:31:29 +07:00
a700db0873 Split authentication.go in one file per check 2025-10-25 03:31:29 +07:00
115da72874 Handle x-google-dkim authentication result 2025-10-25 03:31:29 +07:00
255027d00b Start bad spam score to B 2025-10-25 03:31:29 +07:00
9970e957d5 Handle SPF redirect 2025-10-25 03:31:29 +07:00
8fe8581b78 Handle declined auth result 2025-10-25 03:31:29 +07:00
aa35ab223d Align no DMARC authentication with on DMARC DNS record: grade C 2025-10-25 03:31:29 +07:00
29cb2cf1f9 Headers value for Date and email related are now parsed 2025-10-25 03:31:29 +07:00
7ed347c86e Improve test display in some circonstancies 2025-10-25 03:31:29 +07:00
4bbba66a81 Handle local postfix delivery 2025-10-25 03:31:29 +07:00
8b3ab541ba Add a summary after score 2025-10-25 03:31:29 +07:00
3588af3267 Add links to section 2025-10-25 03:31:29 +07:00
c1063cb4aa Add iprev check 2025-10-25 03:31:29 +07:00
3d03bfc4fa Handle relaxed domain match 2025-10-25 03:31:29 +07:00
84a504d668 Add reverse lookup and forward confirmation 2025-10-25 03:31:29 +07:00
7bc7e7b7a2 Reuse domain extractes from headers 2025-10-25 03:28:44 +07:00
326abc0744 Detect SPF all mechanism 2025-10-25 03:28:44 +07:00
a6448a1533 Split DnsRecordsCard in several components 2025-10-25 03:28:44 +07:00
e5c678174c Comprehensive DMARC record checks 2025-10-25 03:28:44 +07:00
5d335c6a6c Add email-path checks 2025-10-25 03:28:44 +07:00
a97729fea6 Tests design and descriptions 2025-10-24 09:56:35 +07:00
4149a5de92 Truncate DKIM record 2025-10-24 09:56:35 +07:00
8ca4bed875 SPF check return-path 2025-10-24 09:56:35 +07:00
f6a1ea73a2 Check SPF include 2025-10-24 09:56:35 +07:00
a64b866cfa Add grades 2025-10-24 09:56:35 +07:00
33d394a27b Improve content analyzing and reporting 2025-10-23 18:09:23 +07:00
41013d8af2 Improve headers reporting 2025-10-23 18:09:23 +07:00
e77bffb04f Improve spamassassin report 2025-10-23 18:09:23 +07:00
abfd1f0155 Add a score to DNS 2025-10-23 18:09:23 +07:00
fdb43533cd Prepend Return-Path header when receiving email 2025-10-23 18:09:23 +07:00
eb210e7bed Align spamassassin report 2025-10-23 18:09:23 +07:00
c51f8e5904 Improve authentication results 2025-10-23 18:09:23 +07:00
866cf2e5db Refactor spam score 2025-10-23 18:09:23 +07:00
eadc7ff8ca docker: Use spamass-milter 2025-10-23 18:09:23 +07:00
0581e0cf6b Use authentication_milter instead of opendkim and opendmarc 2025-10-23 18:09:23 +07:00
ec1ab7886e Rework DNS results 2025-10-23 10:40:12 +07:00
d87b0cbcb0 Remove checks 2025-10-23 10:40:12 +07:00
954a9d705e Change RBL test to return map of ips 2025-10-21 14:42:18 +07:00
1be917136c Handle RBL error report 2025-10-21 12:52:38 +07:00
74aee54432 Score as percentages 2025-10-21 12:52:20 +07:00
dfc0eeb323 New route to perform a new analysis of the email
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-20 19:33:23 +07:00
0ac51ac06d Use grade instead of numeral notation 2025-10-20 19:25:48 +07:00
e3d89dc953 Add colors on scores 2025-10-20 15:39:12 +07:00
8bf3b500d9 Create a new test when visiting /test/ 2025-10-20 15:30:45 +07:00
0107858ee6 Simplify copy of the address 2025-10-20 15:30:45 +07:00
4d637214de Improve responsiveness 2025-10-20 15:30:45 +07:00
849bdb53c5 Use base32 encoded UUID to reduce address size 2025-10-20 15:30:45 +07:00
3f5e2c6dd4 Add option to add custom content before </head> and </body> 2025-10-20 15:13:53 +07:00
0084fd9660 Indicate when the next inbox check will be done 2025-10-20 15:13:53 +07:00
a687f5cb6b Improve branding 2025-10-20 15:13:53 +07:00
243ca4ba11 Add a banner in README.md 2025-10-20 15:13:53 +07:00
3c58f5ccd5 Lock file maintenance 2025-10-20 15:13:53 +07:00
1fa7af4c2b Fix spamassassin report details 2025-10-20 15:13:53 +07:00
30f774c1fb Expose analyzer 2025-10-20 15:13:53 +07:00
cd40b7c3ea Refactor authentication.go 2025-10-20 15:13:53 +07:00
8313fd7d98 Implement ARC header check 2025-10-20 15:08:45 +07:00
6097eb54c6 Implement BIMI checks 2025-10-20 15:08:45 +07:00
7e603ddf4a go mod tidy 2025-10-20 15:08:45 +07:00
e7aa80bef4 Update module golang.org/x/net to v0.46.0 2025-10-20 15:08:45 +07:00
b2bbf0ee78 Add an auto-cleanup worker 2025-10-20 15:08:45 +07:00
6096e043c6 Update dependency @hey-api/openapi-ts to v0.85.2 2025-10-20 15:08:45 +07:00
9ff2ca30cc Add CI/CD 2025-10-20 15:08:45 +07:00
4304784796 Add an auto-cleanup worker 2025-10-20 15:08:45 +07:00
6565c6fda4 Update dependency @hey-api/openapi-ts to v0.85.2 2025-10-20 15:08:44 +07:00
079dc6a813 Update module github.com/quic-go/quic-go to v0.54.1 [SECURITY] 2025-10-20 15:08:44 +07:00
16a0f3a158 Add renovate.json 2025-10-20 15:08:44 +07:00
20f5b37e5e Create test on email arrival 2025-10-20 15:08:44 +07:00
3867fa36a2 Add LMTP server 2025-10-20 15:02:14 +07:00
18c2f95112 Refactor main.go 2025-10-20 15:02:14 +07:00
4b9733531e Implement web ui 2025-10-20 15:02:14 +07:00
682ca6bb20 Web UI setup 2025-10-20 15:02:14 +07:00
3d823dedd8 Add AIO Dockerfile 2025-10-20 15:02:14 +07:00
62bb85ebec Glue things together 2025-10-20 15:02:14 +07:00
449a8a2c67 Handle config, parse flags 2025-10-19 15:47:38 +07:00
142 changed files with 18937 additions and 8705 deletions

View file

@ -9,7 +9,7 @@ platform:
steps:
- name: frontend
image: node:22-alpine
image: node:24-alpine
commands:
- cd web
- npm install --network-timeout=100000
@ -21,7 +21,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
- go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
environment:
CGO_ENABLED: 0
@ -35,7 +35,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
environment:
CGO_ENABLED: 0
@ -47,7 +47,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
environment:
CGO_ENABLED: 0
GOOS: darwin
@ -61,7 +61,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
environment:
CGO_ENABLED: 0
GOOS: darwin

View file

@ -1,6 +1,6 @@
# Multi-stage Dockerfile for happyDeliver with integrated MTA
# Stage 1: Build the Svelte application
FROM node:22-alpine AS nodebuild
FROM node:24-alpine AS nodebuild
WORKDIR /build
@ -31,19 +31,97 @@ COPY --from=nodebuild /build/web/build/ ./web/build/
RUN go generate ./... && \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
# Stage 3: Runtime image with Postfix and all filters
# Stage 3: Prepare perl and spamass-milt
FROM alpine:3 AS pl
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
apk add --no-cache \
build-base \
libmilter-dev \
musl-obstack-dev \
openssl \
openssl-dev \
perl-app-cpanminus \
perl-alien-libxml2 \
perl-class-load-xs \
perl-cpanel-json-xs \
perl-crypt-openssl-rsa \
perl-crypt-openssl-random \
perl-crypt-openssl-verify \
perl-crypt-openssl-x509 \
perl-dbd-sqlite \
perl-dbi \
perl-email-address-xs \
perl-json-xs \
perl-list-moreutils \
perl-moose \
perl-net-idn-encode@edge \
perl-net-ssleay \
perl-netaddr-ip \
perl-package-stash \
perl-params-util \
perl-params-validate \
perl-proc-processtable \
perl-sereal-decoder \
perl-sereal-encoder \
perl-socket6 \
perl-sub-identify \
perl-variable-magic \
perl-xml-libxml \
perl-dev \
spamassassin-client \
zlib-dev \
&& \
ln -s /usr/bin/ld /bin/ld
RUN cpanm --notest Mail::SPF && \
cpanm --notest Mail::Milter::Authentication
RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \
tar xzf spamass-milter-0.4.0.tar.gz && \
cd spamass-milter-0.4.0 && \
./configure && make install
# Stage 4: Runtime image with Postfix and all filters
FROM alpine:3
# Install all required packages
RUN apk add --no-cache \
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
apk add --no-cache \
bash \
ca-certificates \
opendkim \
opendkim-utils \
opendmarc \
libmilter \
openssl \
perl \
perl-alien-libxml2 \
perl-class-load-xs \
perl-cpanel-json-xs \
perl-crypt-openssl-rsa \
perl-crypt-openssl-random \
perl-crypt-openssl-verify \
perl-crypt-openssl-x509 \
perl-dbd-sqlite \
perl-dbi \
perl-email-address-xs \
perl-json-xs \
perl-list-moreutils \
perl-moose \
perl-net-idn-encode@edge \
perl-net-ssleay \
perl-netaddr-ip \
perl-package-stash \
perl-params-util \
perl-params-validate \
perl-proc-processtable \
perl-sereal-decoder \
perl-sereal-encoder \
perl-socket6 \
perl-sub-identify \
perl-variable-magic \
perl-xml-libxml \
postfix \
postfix-pcre \
postfix-policyd-spf-perl \
rspamd \
spamassassin \
spamassassin-client \
supervisor \
@ -51,8 +129,8 @@ RUN apk add --no-cache \
tzdata \
&& rm -rf /var/cache/apk/*
# Get test-only version of postfix-policyd-spf-perl
ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
# Copy Mail::Milter::Authentication and its dependancies
COPY --from=pl /usr/local/ /usr/local/
# Create happydeliver user and group
RUN addgroup -g 1000 happydeliver && \
@ -62,12 +140,15 @@ RUN addgroup -g 1000 happydeliver && \
RUN mkdir -p /etc/happydeliver \
/var/lib/happydeliver \
/var/log/happydeliver \
/var/spool/postfix/opendkim \
/var/spool/postfix/opendmarc \
/etc/opendkim/keys \
/var/cache/authentication_milter \
/var/lib/authentication_milter \
/var/spool/postfix/authentication_milter \
/var/spool/postfix/spamassassin \
/var/spool/postfix/rspamd \
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
&& chown -R opendkim:postfix /var/spool/postfix/opendkim \
&& chown -R opendmarc:postfix /var/spool/postfix/opendmarc
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \
&& chown rspamd:mail /var/spool/postfix/rspamd \
&& chmod 750 /var/spool/postfix/rspamd
# Copy the built application
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
@ -75,9 +156,9 @@ RUN chmod +x /usr/local/bin/happyDeliver
# Copy configuration files
COPY docker/postfix/ /etc/postfix/
COPY docker/opendkim/ /etc/opendkim/
COPY docker/opendmarc/ /etc/opendmarc/
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
COPY docker/spamassassin/ /etc/mail/spamassassin/
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
COPY docker/supervisor/ /etc/supervisor/
COPY docker/entrypoint.sh /entrypoint.sh
@ -89,11 +170,20 @@ 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
# Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1
# Set entrypoint
ENTRYPOINT ["/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]

122
README.md
View file

@ -1,28 +1,32 @@
# happyDeliver
# happyDeliver - Email Deliverability Tester
![banner](banner.webp)
An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring.
## Features
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
- **REST API**: Full-featured API for creating tests and retrieving reports
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
- **Database Storage**: SQLite or PostgreSQL support
- **Configurable**: via environment or config file for all settings
![A sample deliverability report](web/static/img/report.webp)
## Quick Start
### With Docker (Recommended)
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application.
#### What's included in the Docker container:
- **Postfix MTA**: Receives emails on port 25
- **OpenDKIM**: DKIM signature verification
- **OpenDMARC**: DMARC policy validation
- **authentication_milter**: Entreprise grade email authentication
- **SpamAssassin**: Spam scoring and analysis
- **rspamd**: Second spam filter for cross-validated scoring
- **happyDeliver API**: REST API server on port 8080
- **SQLite Database**: Persistent storage for tests and reports
@ -34,7 +38,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
cd happydeliver
# Edit docker-compose.yml to set your domain
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
# Change HAPPYDELIVER_DOMAIN environment variable and hostname
# Build and start
docker-compose up -d
@ -60,12 +64,86 @@ docker run -d \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
-e HOSTNAME=mail.yourdomain.com \
--hostname mail.yourdomain.com \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
#### 3. Configure TLS Certificates (Optional but Recommended)
To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments.
##### Using docker-compose
Add the certificate paths to your `docker-compose.yml`:
```yaml
environment:
- POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt
- POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key
volumes:
- /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro
- /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro
```
##### Using docker run
```bash
docker run -d \
--name happydeliver \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
-e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \
-e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \
--hostname mail.yourdomain.com \
-v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \
-v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
**Notes:**
- The certificate file should contain the full certificate chain (certificate + intermediate CAs)
- The private key file must be readable by the postfix user inside the container
- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required)
- If both environment variables are not set, Postfix will run without TLS support
#### 4. Configure Network and DNS
##### Open SMTP Port
Port 25 (SMTP) must be accessible from the internet to receive test emails:
```bash
# Check if port 25 is listening
netstat -ln | grep :25
# Allow port 25 through firewall (example with ufw)
sudo ufw allow 25/tcp
# For iptables
sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT
```
**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support.
##### Configure DNS Records
Point your domain to the server's IP address.
```
yourdomain.com. IN A 203.0.113.10
yourdomain.com. IN AAAA 2001:db8::10
```
Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly.
There is no need for an MX record here since the same host will serve both HTTP and SMTP.
### Manual Build
#### 1. Build
@ -85,7 +163,7 @@ The server will start on `http://localhost:8080` by default.
#### 3. Integrate with your existing e-mail setup
It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
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:
@ -106,9 +184,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi
```
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost:2525
# Pattern: test-<base32-uuid>@yourdomain.com -> LMTP on localhost:2525
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
/^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
```
3. Append the created file to `transport_maps` in your `main.cf`:
@ -142,7 +220,7 @@ Response:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "test-550e8400@localhost",
"email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
"status": "pending",
"message": "Send your test email to the address above"
}
@ -186,20 +264,14 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
## Scoring System
The deliverability score is calculated from 0 to 10 based on:
The deliverability score is calculated from A to F based on:
- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
- **Spam (2 pts)**: SpamAssassin score
- **Blacklist (2 pts)**: RBL/DNSBL checks
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
- **Headers (1 pt)**: Required headers, MIME structure
**Ratings:**
- 9-10: Excellent
- 7-8.9: Good
- 5-6.9: Fair
- 3-4.9: Poor
- 0-2.9: Critical
- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
- **Blacklist**: RBL/DNSBL checks
- **Headers**: Required headers, MIME structure, Domain alignment
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
- **Content**: HTML quality, links, images, unsubscribe
## Funding

File diff suppressed because it is too large Load diff

BIN
banner.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -29,13 +29,12 @@ import (
"git.happydns.org/happyDeliver/internal/app"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/version"
)
const version = "0.1.0-dev"
func main() {
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
fmt.Printf("Version: %s\n", version)
fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform")
fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version)
cfg, err := config.ConsolidateConfig()
if err != nil {
@ -53,8 +52,20 @@ func main() {
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
log.Fatalf("Analyzer error: %v", err)
}
case "backup":
if err := app.RunBackup(cfg); err != nil {
log.Fatalf("Backup error: %v", err)
}
case "restore":
inputFile := ""
if len(flag.Args()) >= 2 {
inputFile = flag.Args()[1]
}
if err := app.RunRestore(cfg, inputFile); err != nil {
log.Fatalf("Restore error: %v", err)
}
case "version":
fmt.Println(version)
fmt.Println(version.Version)
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
@ -64,9 +75,11 @@ func main() {
func printUsage() {
fmt.Println("\nCommand availables:")
fmt.Println(" happyDeliver server - Start the API server")
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
fmt.Println(" happyDeliver version - Print version information")
fmt.Println(" happyDeliver server - Start the API server")
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
fmt.Println(" happyDeliver backup - Backup database to stdout as JSON")
fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin")
fmt.Println(" happyDeliver version - Print version information")
fmt.Println("")
flag.Usage()
}

View file

@ -3,14 +3,14 @@ services:
build:
context: .
dockerfile: Dockerfile
image: happydeliver:latest
image: happydomain/happydeliver:latest
container_name: happydeliver
# Set a hostname
hostname: mail.happydeliver.local
environment:
# Set your domain and hostname
DOMAIN: happydeliver.local
HOSTNAME: mail.happydeliver.local
# Set your domain
HAPPYDELIVER_DOMAIN: happydeliver.local
ports:
# SMTP port
@ -23,18 +23,9 @@ services:
- ./data:/var/lib/happydeliver
# Log files
- ./logs:/var/log/happydeliver
# Optional: Override config
# - ./custom-config.yaml:/etc/happydeliver/config.yaml
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
data:
logs:

View file

@ -109,12 +109,13 @@ Default configuration for the Docker environment:
The container accepts these environment variables:
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
Note that the hostname of the container is used to filter the authentication tests results.
Example:
```bash
docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
```
## Volumes

View file

@ -0,0 +1,75 @@
{
"logtoerr" : "1",
"error_log" : "",
"connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock",
"umask" : "0007",
"runas" : "mail",
"rungroup" : "mail",
"authserv_id" : "__HOSTNAME__",
"connect_timeout" : 30,
"command_timeout" : 30,
"content_timeout" : 300,
"dns_timeout" : 10,
"dns_retry" : 2,
"handlers" : {
"Sanitize" : {
"hosts_to_remove" : [
"__HOSTNAME__"
],
"extra_auth_results_types" : [
"X-Spam-Status",
"X-Spam-Report",
"X-Spam-Level",
"X-Spam-Checker-Version"
]
},
"SPF" : {
"hide_none" : 0
},
"DKIM" : {
"hide_none" : 0,
},
"XGoogleDKIM" : {
"hide_none" : 1,
},
"ARC" : {
"hide_none" : 0,
},
"DMARC" : {
"hide_none" : 0,
"detect_list_id" : "1"
},
"BIMI" : {},
"PTR" : {},
"SenderID" : {
"hide_none" : 1
},
"IPRev" : {},
"Auth" : {},
"AlignedFrom" : {},
"LocalIP" : {},
"TrustedIP" : {
"trusted_ip_list" : []
},
"!AddID" : {},
"ReturnOK" : {}
}
}

View file

@ -0,0 +1,58 @@
; This is YOU. DMARC reports include information about the reports. Enter it here.
[organization]
domain = example.com
org_name = My Company Limited
email = admin@example.com
extra_contact_info = http://example.com
; aggregate DMARC reports need to be stored somewhere. Any database
; with a DBI module (MySQL, SQLite, DBD, etc.) should work.
; SQLite and MySQL are tested.
; Default is sqlite.
[report_store]
backend = SQL
;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite
dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306
user = authmilterusername
pass = authmiltpassword
; backend can be perl or libopendmarc
[dmarc]
backend = perl
[dns]
timeout = 5
public_suffix_list = share/public_suffix_list
[smtp]
; hostname is the external FQDN of this MTA
hostname = mx1.example.com
cc = dmarc.copy@example.com
; list IP addresses to whitelist (bypass DMARC reject/quarantine)
; see sample whitelist in share/dmarc_whitelist
whitelist = /path/to/etc/dmarc_whitelist
; By default, we attempt to email directly to the report recipient.
; Set these to relay via a SMTP smart host.
smarthost = mx2.example.com
smartuser = dmarccopyusername
smartpass = dmarccopypassword
[imap]
server = mail.example.com
user =
pass =
; the imap folder where new dmarc messages will be found
folder = dmarc
; the folders to store processed reports (a=aggregate, f=forensic)
f_done = dmarc.forensic
a_done = dmarc.aggregate
[http]
port = 8080
[https]
port = 8443
ssl_crt =
ssl_key =

View file

@ -4,34 +4,42 @@ set -e
echo "Starting happyDeliver container..."
# Get environment variables with defaults
HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
echo "Hostname: $HOSTNAME"
echo "Domain: $HAPPYDELIVER_DOMAIN"
# Create runtime directories
mkdir -p /var/run/opendkim /var/run/opendmarc
chown opendkim:postfix /var/run/opendkim
chown opendmarc:postfix /var/run/opendmarc
# Create socket directories
mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
chown opendkim:postfix /var/spool/postfix/opendkim
chown opendmarc:postfix /var/spool/postfix/opendmarc
chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
mkdir -p /var/spool/postfix/authentication_milter
chown mail:mail /var/spool/postfix/authentication_milter
chmod 750 /var/spool/postfix/authentication_milter
mkdir -p /var/spool/postfix/rspamd
chown rspamd:mail /var/spool/postfix/rspamd
chmod 750 /var/spool/postfix/rspamd
# Create log directory
mkdir -p /var/log/happydeliver
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
chown happydeliver:happydeliver /var/log/happydeliver
chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter
# Replace placeholders in Postfix configuration
echo "Configuring Postfix..."
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
# Replace placeholders in OpenDMARC configuration
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf
# Add certificates to postfix
[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && {
cat <<EOF >> /etc/postfix/main.cf
smtpd_tls_cert_file = ${POSTFIX_CERT_FILE}
smtpd_tls_key_file = ${POSTFIX_KEY_FILE}
smtpd_tls_security_level = may
EOF
}
# Replace placeholders in configurations
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
# Initialize Postfix aliases
if [ -f /etc/postfix/aliases ]; then

View file

@ -1,39 +0,0 @@
# OpenDKIM configuration for happyDeliver
# Verifies DKIM signatures on incoming emails
# Log to syslog
Syslog yes
SyslogSuccess yes
LogWhy yes
# Run as this user and group
UserID opendkim:mail
UMask 002
# Socket for Postfix communication
Socket unix:/var/spool/postfix/opendkim/opendkim.sock
# Process ID file
PidFile /var/run/opendkim/opendkim.pid
# Operating mode - verify only (not signing)
Mode v
# Canonicalization methods
Canonicalization relaxed/simple
# DNS timeout
DNSTimeout 5
# Add header for verification results
AlwaysAddARHeader yes
# Accept unsigned mail
On-NoSignature accept
# Always add Authentication-Results header
AlwaysAddARHeader yes
# Maximum verification attempts
MaximumSignaturesToVerify 3

View file

@ -1,41 +0,0 @@
# OpenDMARC configuration for happyDeliver
# Verifies DMARC policies on incoming emails
# Socket for Postfix communication
Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock
# Process ID file
PidFile /var/run/opendmarc/opendmarc.pid
# Run as this user and group
UserID opendmarc:mail
UMask 002
# Syslog configuration
Syslog true
SyslogFacility mail
# Ignore authentication results from other hosts
IgnoreAuthenticatedClients true
# Accept mail even if DMARC fails (we're analyzing, not filtering)
RejectFailures false
# Trust Authentication-Results headers from localhost only
TrustedAuthservIDs __HOSTNAME__
# Add DMARC results to Authentication-Results header
#AddAuthenticationResults true
# DNS timeout
DNSTimeout 5
# History file (for reporting)
# HistoryFile /var/spool/opendmarc/opendmarc.dat
# Ignore hosts file
# IgnoreHosts /etc/opendmarc/ignore.hosts
# Public suffix list
# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat

View file

@ -10,7 +10,7 @@ inet_interfaces = all
inet_protocols = ipv4
# Recipient settings
mydestination = $myhostname, localhost.$mydomain, localhost
mydestination = localhost.$mydomain, localhost
mynetworks = 127.0.0.0/8 [::1]/128
# Relay settings - accept mail for our test domain
@ -28,14 +28,13 @@ transport_maps = pcre:/etc/postfix/transport_maps
# OpenDKIM for DKIM verification
milter_default_action = accept
milter_protocol = 6
smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
non_smtpd_milters = $smtpd_milters
# SPF policy checking
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination,
check_policy_service unix:private/policy-spf
reject_unauth_destination
# Logging
debug_peer_level = 2

View file

@ -2,7 +2,6 @@
# SMTP service
smtp inet n - n - - smtpd
-o content_filter=spamassassin
# Pickup service
pickup unix n - n 60 1 pickup
@ -74,10 +73,6 @@ scache unix - - n - 1 scache
maildrop unix - n n - - pipe
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
# SPF policy service
policy-spf unix - n n - 0 spawn
user=nobody argv=/usr/bin/postfix-policyd-spf-perl
# SpamAssassin content filter
spamassassin unix - n n - - pipe
user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient}

View file

@ -1,4 +1,4 @@
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@domain.com -> LMTP on localhost:2525
# Pattern: test-<base32-uuid>@domain.com -> LMTP on localhost:2525
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525
/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525

View file

@ -0,0 +1,5 @@
no_action = 0;
reject = null;
add_header = null;
rewrite_subject = null;
greylist = null;

View file

@ -0,0 +1,5 @@
# Add "extended Rspamd headers"
extended_spam_headers = true;
skip_local = false;
skip_authenticated = false;

View file

@ -0,0 +1,3 @@
# rspamd options for happyDeliver
# Disable Bayes learning to keep the setup stateless
use_redis = false;

View file

@ -0,0 +1,6 @@
# Enable rspamd milter proxy worker via Unix socket for Postfix integration
bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
upstream "local" {
default = yes;
self_scan = yes;
}

View file

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

View file

@ -22,27 +22,26 @@ autostart=true
autorestart=true
priority=9
# OpenDKIM service
[program:opendkim]
command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf
# Authentication Milter service
[program:authentication_milter]
command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid
autostart=true
autorestart=true
priority=10
stdout_logfile=/var/log/happydeliver/opendkim.log
stderr_logfile=/var/log/happydeliver/opendkim_error.log
user=opendkim
stdout_logfile=/var/log/happydeliver/authentication_milter.log
stderr_logfile=/var/log/happydeliver/authentication_milter.log
user=mail
group=mail
# OpenDMARC service
[program:opendmarc]
command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf
# rspamd spam filter
[program:rspamd]
command=/usr/bin/rspamd -f -u rspamd -g mail
autostart=true
autorestart=true
priority=11
stdout_logfile=/var/log/happydeliver/opendmarc.log
stderr_logfile=/var/log/happydeliver/opendmarc_error.log
user=opendmarc
group=mail
stdout_logfile=/var/log/happydeliver/rspamd.log
stderr_logfile=/var/log/happydeliver/rspamd_error.log
user=root
# SpamAssassin daemon
[program:spamd]
@ -54,6 +53,18 @@ stdout_logfile=/var/log/happydeliver/spamd.log
stderr_logfile=/var/log/happydeliver/spamd_error.log
user=root
# SpamAssassin milter
[program:spamass_milter]
command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m
autostart=true
autorestart=true
priority=7
stdout_logfile=/var/log/happydeliver/spamass-milter.log
stderr_logfile=/var/log/happydeliver/spamass-milter_error.log
user=mail
group=mail
umask=007
# Postfix service
[program:postfix]
command=/usr/sbin/postfix start-fg

61
go.mod
View file

@ -3,35 +3,40 @@ module git.happydns.org/happyDeliver
go 1.24.6
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6
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/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.1.2
golang.org/x/net v0.45.0
golang.org/x/net v0.50.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.0
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/getkin/kin-openapi v0.132.0 // 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.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // 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.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
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.7.6 // indirect
github.com/jackc/pgx/v5 v5.8.0 // 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
@ -39,33 +44,35 @@ require (
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.7.7 // 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.32 // 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.0 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
go.uber.org/mock v0.6.0 // 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
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

136
go.sum
View file

@ -1,7 +1,21 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
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/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=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@ -10,6 +24,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
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/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/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=
@ -20,33 +36,35 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
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/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.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
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/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=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
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.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -72,8 +90,8 @@ 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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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/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=
@ -84,6 +102,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
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=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -95,12 +114,12 @@ 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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -111,8 +130,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
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.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
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=
@ -137,10 +156,12 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
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/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
@ -149,37 +170,42 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g
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/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=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
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=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
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/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=
@ -187,13 +213,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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
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/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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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/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=
@ -209,21 +235,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
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/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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
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/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=
@ -236,8 +262,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -259,5 +285,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View file

@ -1,511 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct{}
// NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &api.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
// If no Authentication-Results headers, try to parse legacy headers
if results.Spf == nil {
results.Spf = a.parseLegacySPF(email)
}
if results.Dkim == nil || len(*results.Dkim) == 0 {
dkimResults := a.parseLegacyDKIM(email)
if len(dkimResults) > 0 {
results.Dkim = &dkimResults
}
}
return results
}
// 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) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
return
}
// Skip the authserv-id (first part)
for i := 1; i < len(parts); i++ {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
// Parse SPF
if strings.HasPrefix(part, "spf=") {
if results.Spf == nil {
results.Spf = a.parseSPFResult(part)
}
}
// Parse DKIM
if strings.HasPrefix(part, "dkim=") {
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
}
}
}
// Parse DMARC
if strings.HasPrefix(part, "dmarc=") {
if results.Dmarc == nil {
results.Dmarc = a.parseDMARCResult(part)
}
}
}
}
// 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{}
// 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)
}
// Extract domain
domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
email := matches[1]
// Extract domain from email
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
return result
}
// 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{}
// 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)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.s or s)
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
return result
}
// 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{}
// 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)
}
// Extract domain (header.from)
domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract details (action, policy, etc.)
var detailsParts []string
actionRe := regexp.MustCompile(`action=([^\s;]+)`)
if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 {
detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1]))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, " ")
result.Details = &details
}
return result
}
// parseLegacySPF attempts to parse SPF from Received-SPF header
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
return nil
}
result := &api.AuthResult{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
result.Result = api.AuthResultResult(resultStr)
}
// Try to extract domain
domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
email := matches[1]
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
return result
}
// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header
func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult {
var results []api.AuthResult
// Get all DKIM-Signature headers
dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")]
for _, dkimHeader := range dkimHeaders {
result := api.AuthResult{
Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone
}
// Extract domain (d=)
domainRe := regexp.MustCompile(`d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (s=)
selectorRe := regexp.MustCompile(`s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
details := "DKIM signature present (verification status unknown)"
result.Details = &details
results = append(results, result)
}
return results
}
// textprotoCanonical converts a header name to canonical form
func textprotoCanonical(s string) string {
// Simple implementation - capitalize each word
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
}
return strings.Join(words, "-")
}
// GetAuthenticationScore calculates the authentication score (0-3 points)
func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
var score float32 = 0.0
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
score += 1.0
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
score += 0.5
}
}
// DKIM: 1 point for at least one pass
if results.Dkim != nil && len(*results.Dkim) > 0 {
for _, dkim := range *results.Dkim {
if dkim.Result == api.AuthResultResultPass {
score += 1.0
break
}
}
}
// DMARC: 1 point for pass
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
score += 1.0
}
}
// Cap at 3 points maximum
if score > 3.0 {
score = 3.0
}
return score
}
// GenerateAuthenticationChecks generates check results for authentication
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
var checks []api.Check
// SPF check
if results.Spf != nil {
check := a.generateSPFCheck(results.Spf)
checks = append(checks, check)
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "SPF Record",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No SPF authentication result found",
Severity: api.PtrTo(api.Medium),
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
})
}
// DKIM check
if results.Dkim != nil && len(*results.Dkim) > 0 {
for i, dkim := range *results.Dkim {
check := a.generateDKIMCheck(&dkim, i)
checks = append(checks, check)
}
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "DKIM Signature",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No DKIM signature found",
Severity: api.PtrTo(api.Medium),
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
})
}
// DMARC check
if results.Dmarc != nil {
check := a.generateDMARCCheck(results.Dmarc)
checks = append(checks, check)
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "DMARC Policy",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No DMARC authentication result found",
Severity: api.PtrTo(api.Medium),
Advice: api.PtrTo("Implement DMARC policy for your domain"),
})
}
return checks
}
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "SPF Record",
}
switch spf.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "SPF validation passed"
check.Severity = api.PtrTo(api.Info)
check.Advice = api.PtrTo("Your SPF record is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = "SPF validation failed"
check.Severity = api.PtrTo(api.Critical)
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
case api.AuthResultResultSoftfail:
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF validation softfail"
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("Review your SPF record configuration")
case api.AuthResultResultNeutral:
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF validation neutral"
check.Severity = api.PtrTo(api.Low)
check.Advice = api.PtrTo("Consider tightening your SPF policy")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("Review your SPF record configuration")
}
if spf.Domain != nil {
details := fmt.Sprintf("Domain: %s", *spf.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
check := api.Check{
Category: api.Authentication,
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
}
switch dkim.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "DKIM signature is valid"
check.Severity = api.PtrTo(api.Info)
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = "DKIM signature validation failed"
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
}
var detailsParts []string
if dkim.Domain != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
}
if dkim.Selector != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "DMARC Policy",
}
switch dmarc.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "DMARC validation passed"
check.Severity = api.PtrTo(api.Info)
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = "DMARC validation failed"
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
}
if dmarc.Domain != nil {
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
check.Details = &details
}
return check
}

View file

@ -1,830 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"unicode"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
// ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct {
Timeout time.Duration
httpClient *http.Client
}
// NewContentAnalyzer creates a new content analyzer with configurable timeout
func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
return &ContentAnalyzer{
Timeout: timeout,
httpClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Allow up to 10 redirects
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// ContentResults represents content analysis results
type ContentResults struct {
HTMLValid bool
HTMLErrors []string
Links []LinkCheck
Images []ImageCheck
HasUnsubscribe bool
UnsubscribeLinks []string
TextContent string
HTMLContent string
TextPlainRatio float32 // Ratio of plain text to HTML consistency
ImageTextRatio float32 // Ratio of images to text
SuspiciousURLs []string
ContentIssues []string
}
// LinkCheck represents a link validation result
type LinkCheck struct {
URL string
Valid bool
Status int
Error string
IsSafe bool
Warning string
}
// ImageCheck represents an image validation result
type ImageCheck struct {
Src string
HasAlt bool
AltText string
Valid bool
Error string
IsBroken bool
}
// AnalyzeContent performs content analysis on email message
func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
results := &ContentResults{}
// Get HTML and text parts
htmlParts := email.GetHTMLParts()
textParts := email.GetTextParts()
// Analyze HTML parts
if len(htmlParts) > 0 {
for _, part := range htmlParts {
c.analyzeHTML(part.Content, results)
}
}
// Analyze text parts
if len(textParts) > 0 {
for _, part := range textParts {
results.TextContent += part.Content
}
}
// Check plain text/HTML consistency
if len(htmlParts) > 0 && len(textParts) > 0 {
results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent)
}
return results
}
// analyzeHTML parses and analyzes HTML content
func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) {
results.HTMLContent = htmlContent
// Parse HTML
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
results.HTMLValid = false
results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err))
return
}
results.HTMLValid = true
// Traverse HTML tree
c.traverseHTML(doc, results)
// Calculate image-to-text ratio
if results.HTMLContent != "" {
textLength := len(c.extractTextFromHTML(htmlContent))
imageCount := len(results.Images)
if textLength > 0 {
results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars
}
}
}
// traverseHTML recursively traverses HTML nodes
func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
// Extract and validate links
href := c.getAttr(n, "href")
if href != "" {
// Check for unsubscribe links
if c.isUnsubscribeLink(href, n) {
results.HasUnsubscribe = true
results.UnsubscribeLinks = append(results.UnsubscribeLinks, href)
}
// Validate link
linkCheck := c.validateLink(href)
results.Links = append(results.Links, linkCheck)
// Check for suspicious URLs
if !linkCheck.IsSafe {
results.SuspiciousURLs = append(results.SuspiciousURLs, href)
}
}
case "img":
// Extract and validate images
src := c.getAttr(n, "src")
alt := c.getAttr(n, "alt")
imageCheck := ImageCheck{
Src: src,
HasAlt: alt != "",
AltText: alt,
Valid: src != "",
}
if src == "" {
imageCheck.Error = "Image missing src attribute"
}
results.Images = append(results.Images, imageCheck)
}
}
// Traverse children
for child := n.FirstChild; child != nil; child = child.NextSibling {
c.traverseHTML(child, results)
}
}
// getAttr gets an attribute value from an HTML node
func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
for _, attr := range n.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
// isUnsubscribeLink checks if a link is an unsubscribe link
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
// Check href for unsubscribe keywords
lowerHref := strings.ToLower(href)
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
for _, keyword := range unsubKeywords {
if strings.Contains(lowerHref, keyword) {
return true
}
}
// Check link text for unsubscribe keywords
text := c.getNodeText(node)
lowerText := strings.ToLower(text)
for _, keyword := range unsubKeywords {
if strings.Contains(lowerText, keyword) {
return true
}
}
return false
}
// getNodeText extracts text content from a node
func (c *ContentAnalyzer) getNodeText(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
var text string
for child := n.FirstChild; child != nil; child = child.NextSibling {
text += c.getNodeText(child)
}
return text
}
// validateLink validates a URL and checks if it's accessible
func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck {
check := LinkCheck{
URL: urlStr,
IsSafe: true,
}
// Parse URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
check.Valid = false
check.Error = fmt.Sprintf("Invalid URL: %v", err)
return check
}
// Check URL safety
if c.isSuspiciousURL(urlStr, parsedURL) {
check.IsSafe = false
check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)"
}
// Only check HTTP/HTTPS links
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
check.Valid = true
return check
}
// Check if link is accessible (with timeout)
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil)
if err != nil {
check.Valid = false
check.Error = fmt.Sprintf("Failed to create request: %v", err)
return check
}
// Set a reasonable user agent
req.Header.Set("User-Agent", "HappyDeliver/1.0 (Email Deliverability Tester)")
resp, err := c.httpClient.Do(req)
if err != nil {
// Don't fail on timeout/connection errors for external links
// Just mark as warning
check.Valid = true
check.Status = 0
check.Warning = fmt.Sprintf("Could not verify link: %v", err)
return check
}
defer resp.Body.Close()
check.Status = resp.StatusCode
check.Valid = true
// Check for error status codes
if resp.StatusCode >= 400 {
check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode)
}
return check
}
// isSuspiciousURL checks if a URL looks suspicious
func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool {
// Check for IP address instead of domain
if c.isIPAddress(parsedURL.Host) {
return true
}
// Check for URL shorteners (common ones)
shorteners := []string{
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
"buff.ly", "is.gd", "bl.ink", "short.io",
}
for _, shortener := range shorteners {
if strings.Contains(strings.ToLower(parsedURL.Host), shortener) {
return true
}
}
// Check for excessive subdomains (possible obfuscation)
parts := strings.Split(parsedURL.Host, ".")
if len(parts) > 4 {
return true
}
// Check for URL obfuscation techniques
if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing)
return true
}
// Check for suspicious characters in domain
if strings.ContainsAny(parsedURL.Host, "[]()<>") {
return true
}
return false
}
// isIPAddress checks if a string is an IP address
func (c *ContentAnalyzer) isIPAddress(host string) bool {
// Remove port if present
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// Simple check for IPv4
parts := strings.Split(host, ".")
if len(parts) == 4 {
for _, part := range parts {
// Check if all characters are digits
for _, ch := range part {
if !unicode.IsDigit(ch) {
return false
}
}
}
return true
}
// Check for IPv6 (contains colons)
if strings.Contains(host, ":") {
return true
}
return false
}
// extractTextFromHTML extracts plain text from HTML
func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return ""
}
var text strings.Builder
var extract func(*html.Node)
extract = func(n *html.Node) {
if n.Type == html.TextNode {
text.WriteString(n.Data)
}
// Skip script and style tags
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
return
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
extract(child)
}
}
extract(doc)
return text.String()
}
// calculateTextPlainConsistency compares plain text and HTML versions
func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 {
// Extract text from HTML
htmlPlainText := c.extractTextFromHTML(htmlText)
// Normalize both texts
plainNorm := c.normalizeText(plainText)
htmlNorm := c.normalizeText(htmlPlainText)
// Calculate similarity using simple word overlap
plainWords := strings.Fields(plainNorm)
htmlWords := strings.Fields(htmlNorm)
if len(plainWords) == 0 || len(htmlWords) == 0 {
return 0.0
}
// Count common words
commonWords := 0
plainWordSet := make(map[string]bool)
for _, word := range plainWords {
plainWordSet[word] = true
}
for _, word := range htmlWords {
if plainWordSet[word] {
commonWords++
}
}
// Calculate ratio (Jaccard similarity approximation)
maxWords := len(plainWords)
if len(htmlWords) > maxWords {
maxWords = len(htmlWords)
}
if maxWords == 0 {
return 0.0
}
return float32(commonWords) / float32(maxWords)
}
// normalizeText normalizes text for comparison
func (c *ContentAnalyzer) normalizeText(text string) string {
// Convert to lowercase
text = strings.ToLower(text)
// Remove extra whitespace
text = strings.TrimSpace(text)
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return text
}
// GenerateContentChecks generates check results for content analysis
func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// HTML validity check
checks = append(checks, c.generateHTMLValidityCheck(results))
// Link checks
checks = append(checks, c.generateLinkChecks(results)...)
// Image checks
checks = append(checks, c.generateImageChecks(results)...)
// Unsubscribe link check
checks = append(checks, c.generateUnsubscribeCheck(results))
// Text/HTML consistency check
if results.TextContent != "" && results.HTMLContent != "" {
checks = append(checks, c.generateTextConsistencyCheck(results))
}
// Image-to-text ratio check
if len(results.Images) > 0 && results.HTMLContent != "" {
checks = append(checks, c.generateImageRatioCheck(results))
}
// Suspicious URLs check
if len(results.SuspiciousURLs) > 0 {
checks = append(checks, c.generateSuspiciousURLCheck(results))
}
return checks
}
// generateHTMLValidityCheck creates a check for HTML validity
func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "HTML Structure",
}
if !results.HTMLValid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Message = "HTML structure is invalid"
if len(results.HTMLErrors) > 0 {
details := strings.Join(results.HTMLErrors, "; ")
check.Details = &details
}
check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.2
check.Severity = api.PtrTo(api.Info)
check.Message = "HTML structure is valid"
check.Advice = api.PtrTo("Your HTML is well-formed")
}
return check
}
// generateLinkChecks creates checks for links
func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check {
var checks []api.Check
if len(results.Links) == 0 {
return checks
}
// Count broken links
brokenLinks := 0
warningLinks := 0
for _, link := range results.Links {
if link.Status >= 400 {
brokenLinks++
} else if link.Warning != "" {
warningLinks++
}
}
check := api.Check{
Category: api.Content,
Name: "Links",
}
if brokenLinks > 0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.High)
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
check.Details = &details
} else if warningLinks > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.3
check.Severity = api.PtrTo(api.Low)
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
check.Advice = api.PtrTo("Review links that could not be verified")
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Severity = api.PtrTo(api.Info)
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
check.Advice = api.PtrTo("Your links are working properly")
}
checks = append(checks, check)
return checks
}
// generateImageChecks creates checks for images
func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check {
var checks []api.Check
if len(results.Images) == 0 {
return checks
}
// Count images without alt text
noAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
noAltCount++
}
}
check := api.Check{
Category: api.Content,
Name: "Image Alt Attributes",
}
if noAltCount == len(results.Images) {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Message = "No images have alt attributes"
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
check.Details = &details
} else if noAltCount > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Severity = api.PtrTo(api.Low)
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Message = "All images have alt attributes"
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
}
checks = append(checks, check)
return checks
}
// generateUnsubscribeCheck creates a check for unsubscribe links
func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Unsubscribe Link",
}
if !results.HasUnsubscribe {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Low)
check.Message = "No unsubscribe link found"
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
}
return check
}
// generateTextConsistencyCheck creates a check for text/HTML consistency
func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Plain Text Consistency",
}
consistency := results.TextPlainRatio
if consistency < 0.3 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Low)
check.Message = "Plain text and HTML versions differ significantly"
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Message = "Plain text and HTML versions are consistent"
check.Advice = api.PtrTo("Your multipart email is well-structured")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
check.Details = &details
}
return check
}
// generateImageRatioCheck creates a check for image-to-text ratio
func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Image-to-Text Ratio",
}
ratio := results.ImageTextRatio
// Flag if more than 1 image per 100 characters (very image-heavy)
if ratio > 10.0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Message = "Email is excessively image-heavy"
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
} else if ratio > 5.0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Severity = api.PtrTo(api.Low)
check.Message = "Email has high image-to-text ratio"
check.Advice = api.PtrTo("Consider adding more text content relative to images")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Message = "Image-to-text ratio is reasonable"
check.Advice = api.PtrTo("Your content has a good balance of images and text")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
}
return check
}
// generateSuspiciousURLCheck creates a check for suspicious URLs
func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Suspicious URLs",
}
count := len(results.SuspiciousURLs)
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
if count <= 3 {
details := strings.Join(results.SuspiciousURLs, ", ")
check.Details = &details
} else {
details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3)
check.Details = &details
}
return check
}
// GetContentScore calculates the content score (0-2 points)
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 {
if results == nil {
return 0.0
}
var score float32 = 0.0
// HTML validity (0.2 points)
if results.HTMLValid {
score += 0.2
}
// Links (0.4 points)
if len(results.Links) > 0 {
brokenLinks := 0
for _, link := range results.Links {
if link.Status >= 400 {
brokenLinks++
}
}
if brokenLinks == 0 {
score += 0.4
}
} else {
// No links is neutral, give partial score
score += 0.2
}
// Images (0.3 points)
if len(results.Images) > 0 {
noAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
noAltCount++
}
}
if noAltCount == 0 {
score += 0.3
} else if noAltCount < len(results.Images) {
score += 0.15
}
} else {
// No images is neutral
score += 0.15
}
// Unsubscribe link (0.3 points)
if results.HasUnsubscribe {
score += 0.3
}
// Text consistency (0.3 points)
if results.TextPlainRatio >= 0.3 {
score += 0.3
}
// Image ratio (0.3 points)
if results.ImageTextRatio <= 5.0 {
score += 0.3
} else if results.ImageTextRatio <= 10.0 {
score += 0.15
}
// Penalize suspicious URLs (deduct up to 0.5 points)
if len(results.SuspiciousURLs) > 0 {
penalty := float32(len(results.SuspiciousURLs)) * 0.1
if penalty > 0.5 {
penalty = 0.5
}
score -= penalty
}
// Ensure score is between 0 and 2
if score < 0 {
score = 0
}
if score > 2.0 {
score = 2.0
}
return score
}

View file

@ -1,566 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
resolver *net.Resolver
}
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
return &DNSAnalyzer{
Timeout: timeout,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// DNSResults represents DNS validation results for an email
type DNSResults struct {
Domain string
MXRecords []MXRecord
SPFRecord *SPFRecord
DKIMRecords []DKIMRecord
DMARCRecord *DMARCRecord
Errors []string
}
// MXRecord represents an MX record
type MXRecord struct {
Host string
Priority uint16
Valid bool
Error string
}
// SPFRecord represents an SPF record
type SPFRecord struct {
Record string
Valid bool
Error string
}
// DKIMRecord represents a DKIM record
type DKIMRecord struct {
Selector string
Domain string
Record string
Valid bool
Error string
}
// DMARCRecord represents a DMARC record
type DMARCRecord struct {
Record string
Policy string // none, quarantine, reject
Valid bool
Error string
}
// AnalyzeDNS performs DNS validation for the email's domain
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
// Extract domain from From address
domain := d.extractDomain(email)
if domain == "" {
return &DNSResults{
Errors: []string{"Unable to extract domain from email"},
}
}
results := &DNSResults{
Domain: domain,
}
// Check MX records
results.MXRecords = d.checkMXRecords(domain)
// Check SPF record
results.SPFRecord = d.checkSPFRecord(domain)
// Check DKIM records (from authentication results)
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)
if dkimRecord != nil {
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
}
}
}
}
// Check DMARC record
results.DMARCRecord = d.checkDMARCRecord(domain)
return results
}
// extractDomain extracts the domain from the email's From address
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
if email.From != nil && email.From.Address != "" {
parts := strings.Split(email.From.Address, "@")
if len(parts) == 2 {
return strings.ToLower(strings.TrimSpace(parts[1]))
}
}
return ""
}
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return []MXRecord{
{
Valid: false,
Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
},
}
}
if len(mxRecords) == 0 {
return []MXRecord{
{
Valid: false,
Error: "No MX records found",
},
}
}
var results []MXRecord
for _, mx := range mxRecords {
results = append(results, MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
return results
}
// checkSPFRecord looks up and validates SPF record for a domain
func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &SPFRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup TXT records: %v", err),
}
}
// Find SPF record (starts with "v=spf1")
var spfRecord string
spfCount := 0
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfRecord = txt
spfCount++
}
}
if spfCount == 0 {
return &SPFRecord{
Valid: false,
Error: "No SPF record found",
}
}
if spfCount > 1 {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "Multiple SPF records found (RFC violation)",
}
}
// Basic validation
if !d.validateSPF(spfRecord) {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "SPF record appears malformed",
}
}
return &SPFRecord{
Record: spfRecord,
Valid: true,
}
}
// validateSPF performs basic SPF record validation
func (d *DNSAnalyzer) validateSPF(record string) bool {
// Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") {
return false
}
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
hasValidEnding := false
for _, ending := range validEndings {
if strings.HasSuffix(record, ending) {
hasValidEnding = true
break
}
}
return hasValidEnding
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err),
}
}
if len(txtRecords) == 0 {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: "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 &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: false,
Error: "DKIM record appears malformed",
}
}
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: true,
}
}
// 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 strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false
}
return true
}
// checkDMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &DMARCRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err),
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if dmarcRecord == "" {
return &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
}
}
// Extract policy
policy := d.extractDMARCPolicy(dmarcRecord)
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: false,
Error: "DMARC record appears malformed",
}
}
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: true,
}
}
// 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]
}
return "unknown"
}
// validateDMARC performs basic DMARC record validation
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
if !strings.Contains(record, "p=") {
return false
}
return true
}
// GenerateDNSChecks generates check results for DNS validation
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// MX record check
checks = append(checks, d.generateMXCheck(results))
// SPF record check
if results.SPFRecord != nil {
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
}
// DKIM record checks
for _, dkim := range results.DKIMRecords {
checks = append(checks, d.generateDKIMCheck(&dkim))
}
// DMARC record check
if results.DMARCRecord != nil {
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
}
return checks
}
// generateMXCheck creates a check for MX records
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
check := api.Check{
Category: api.Dns,
Name: "MX Records",
}
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Critical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
check.Message = results.MXRecords[0].Error
} else {
check.Message = "No valid MX records found"
}
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.Info)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
// Add details about MX records
var mxList []string
for _, mx := range results.MXRecords {
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
}
details := strings.Join(mxList, ", ")
check.Details = &details
check.Advice = api.PtrTo("Your MX records are properly configured")
}
return check
}
// generateSPFCheck creates a check for SPF records
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "SPF Record",
}
if !spf.Valid {
// If no record exists at all, it's a failure
if spf.Record == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = spf.Error
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
} else {
// If record exists but is invalid, it's a warning
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF record found but appears invalid"
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.Info)
check.Details = &spf.Record
check.Advice = api.PtrTo("Your SPF record is properly configured")
}
return check
}
// generateDKIMCheck creates a check for DKIM records
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
}
if !dkim.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.Info)
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
check.Advice = api.PtrTo("Your DKIM record is properly published")
}
return check
}
// generateDMARCCheck creates a check for DMARC records
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "DMARC Record",
}
if !dmarc.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
check.Severity = api.PtrTo(api.Info)
check.Details = &dmarc.Record
// Provide advice based on policy
switch dmarc.Policy {
case "none":
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
check.Advice = &advice
case "quarantine":
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
check.Advice = &advice
case "reject":
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
check.Advice = &advice
default:
advice := "Your DMARC record is properly configured"
check.Advice = &advice
}
}
return check
}

View file

@ -1,633 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDNSAnalyzer(t *testing.T) {
tests := []struct {
name string
timeout time.Duration
expectedTimeout time.Duration
}{
{
name: "Default timeout",
timeout: 0,
expectedTimeout: 10 * time.Second,
},
{
name: "Custom timeout",
timeout: 5 * time.Second,
expectedTimeout: 5 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := NewDNSAnalyzer(tt.timeout)
if analyzer.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout)
}
if analyzer.resolver == nil {
t.Error("Resolver should not be nil")
}
})
}
}
func TestExtractDomain(t *testing.T) {
tests := []struct {
name string
fromAddress string
expectedDomain string
}{
{
name: "Valid email",
fromAddress: "user@example.com",
expectedDomain: "example.com",
},
{
name: "Email with subdomain",
fromAddress: "user@mail.example.com",
expectedDomain: "mail.example.com",
},
{
name: "Email with uppercase",
fromAddress: "User@Example.COM",
expectedDomain: "example.com",
},
{
name: "Invalid email (no @)",
fromAddress: "invalid-email",
expectedDomain: "",
},
{
name: "Empty email",
fromAddress: "",
expectedDomain: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
}
if tt.fromAddress != "" {
email.From = &mail.Address{
Address: tt.fromAddress,
}
}
domain := analyzer.extractDomain(email)
if domain != tt.expectedDomain {
t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
}
})
}
}
func TestValidateSPF(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid SPF with -all",
record: "v=spf1 include:_spf.example.com -all",
expected: true,
},
{
name: "Valid SPF with ~all",
record: "v=spf1 ip4:192.0.2.0/24 ~all",
expected: true,
},
{
name: "Valid SPF with +all",
record: "v=spf1 +all",
expected: true,
},
{
name: "Valid SPF with ?all",
record: "v=spf1 mx ?all",
expected: true,
},
{
name: "Invalid SPF - no version",
record: "include:_spf.example.com -all",
expected: false,
},
{
name: "Invalid SPF - no all mechanism",
record: "v=spf1 include:_spf.example.com",
expected: false,
},
{
name: "Invalid SPF - wrong version",
record: "v=spf2 include:_spf.example.com -all",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateSPF(tt.record)
if result != tt.expected {
t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DKIM with version",
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Valid DKIM without version",
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Invalid DKIM - no public key",
record: "v=DKIM1; k=rsa",
expected: false,
},
{
name: "Invalid DKIM - wrong version",
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: false,
},
{
name: "Invalid DKIM - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDKIM(tt.record)
if result != tt.expected {
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestExtractDMARCPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy string
}{
{
name: "Policy none",
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
expectedPolicy: "none",
},
{
name: "Policy quarantine",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPolicy: "quarantine",
},
{
name: "Policy reject",
record: "v=DMARC1; p=reject; sp=reject",
expectedPolicy: "reject",
},
{
name: "No policy",
record: "v=DMARC1",
expectedPolicy: "unknown",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
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)
}
})
}
}
func TestValidateDMARC(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DMARC",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
expected: true,
},
{
name: "Valid DMARC minimal",
record: "v=DMARC1; p=none",
expected: true,
},
{
name: "Invalid DMARC - no version",
record: "p=quarantine",
expected: false,
},
{
name: "Invalid DMARC - no policy",
record: "v=DMARC1",
expected: false,
},
{
name: "Invalid DMARC - wrong version",
record: "v=DMARC2; p=reject",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDMARC(tt.record)
if result != tt.expected {
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestGenerateMXCheck(t *testing.T) {
tests := []struct {
name string
results *DNSResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
{Host: "mail2.example.com", Priority: 20, Valid: true},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "No MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "No MX records found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "MX lookup failed",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "DNS lookup failed"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateMXCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateSPFCheck(t *testing.T) {
tests := []struct {
name string
spf *SPFRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid SPF",
spf: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Invalid SPF",
spf: &SPFRecord{
Record: "v=spf1 invalid syntax",
Valid: false,
Error: "SPF record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "No SPF record",
spf: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateSPFCheck(tt.spf)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateDKIMCheck(t *testing.T) {
tests := []struct {
name string
dkim *DKIMRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Invalid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Valid: false,
Error: "No DKIM record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDKIMCheck(tt.dkim)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
if !strings.Contains(check.Name, tt.dkim.Selector) {
t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
}
})
}
}
func TestGenerateDMARCCheck(t *testing.T) {
tests := []struct {
name string
dmarc *DMARCRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid DMARC - reject",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=reject",
Policy: "reject",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Valid DMARC - quarantine",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Valid DMARC - none",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=none",
Policy: "none",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "No DMARC record",
dmarc: &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDMARCCheck(tt.dmarc)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
// Check that advice mentions policy for valid DMARC
if tt.dmarc.Valid && check.Advice != nil {
if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
t.Error("Advice should mention 'none' policy")
}
}
})
}
}
func TestGenerateDNSChecks(t *testing.T) {
tests := []struct {
name string
results *DNSResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
SPFRecord: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
DKIMRecords: []DKIMRecord{
{
Selector: "default",
Domain: "example.com",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
},
minChecks: 4, // MX, SPF, DKIM, DMARC
},
{
name: "Partial results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
},
minChecks: 1, // Only MX
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateDNSChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the DNS category
for _, check := range checks {
if check.Category != api.Dns {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
}
}
})
}
}
func TestAnalyzeDNS_NoDomain(t *testing.T) {
analyzer := NewDNSAnalyzer(5 * time.Second)
email := &EmailMessage{
Header: make(mail.Header),
// No From address
}
results := analyzer.AnalyzeDNS(email, nil)
if results == nil {
t.Fatal("Expected results, got nil")
}
if len(results.Errors) == 0 {
t.Error("Expected error when no domain can be extracted")
}
}

View file

@ -1,408 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// RBLChecker checks IP addresses against DNS-based blacklists
type RBLChecker struct {
Timeout time.Duration
RBLs []string
resolver *net.Resolver
}
// DefaultRBLs is a list of commonly used RBL providers
var DefaultRBLs = []string{
"zen.spamhaus.org", // Spamhaus combined list
"bl.spamcop.net", // SpamCop
"dnsbl.sorbs.net", // SORBS
"b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
if timeout == 0 {
timeout = 5 * time.Second // Default timeout
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
return &RBLChecker{
Timeout: timeout,
RBLs: rbls,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// RBLResults represents the results of RBL checks
type RBLResults struct {
Checks []RBLCheck
IPsChecked []string
ListedCount int
}
// RBLCheck represents a single RBL check result
type RBLCheck struct {
IP string
RBL string
Listed bool
Response string
Error string
}
// CheckEmail checks all IPs found in the email headers against RBLs
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results := &RBLResults{}
// Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
}
results.IPsChecked = ips
// Check each IP against all RBLs
for _, ip := range ips {
for _, rbl := range r.RBLs {
check := r.checkIP(ip, rbl)
results.Checks = append(results.Checks, check)
if check.Listed {
results.ListedCount++
}
}
}
return results
}
// extractIPs extracts IP addresses from Received headers
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
// Get all Received headers
receivedHeaders := email.Header["Received"]
// Regex patterns for IP addresses
// Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
// Look for IPs in Received headers
for _, received := range receivedHeaders {
// Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
// Skip private/reserved IPs
if !r.isPublicIP(match) {
continue
}
// Avoid duplicates
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
}
}
}
// If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
// Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
ips = append(ips, matches)
}
}
}
return ips
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *RBLChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
// Additional checks for reserved ranges
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() {
return false
}
return true
}
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
check := RBLCheck{
IP: ip,
RBL: rbl,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = "Failed to reverse IP address"
return check
}
// Construct DNSBL query: reversed-ip.rbl-domain
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
// Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
// Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
// Other DNS errors
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
return check
}
// If we got a response, the IP is listed
if len(addrs) > 0 {
check.Listed = true
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
}
return check
}
// reverseIP reverses an IPv4 address for DNSBL queries
// Example: 192.0.2.1 -> 1.2.0.192
func (r *RBLChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
// Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
// Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points)
// Scoring:
// - Not listed on any RBL: 2 points (excellent)
// - Listed on 1 RBL: 1 point (warning)
// - Listed on 2-3 RBLs: 0.5 points (poor)
// - Listed on 4+ RBLs: 0 points (critical)
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 {
if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 2.0
}
listedCount := results.ListedCount
if listedCount == 0 {
return 2.0
} else if listedCount == 1 {
return 1.0
} else if listedCount <= 3 {
return 0.5
}
return 0.0
}
// GenerateRBLChecks generates check results for RBL analysis
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// If no IPs were checked, add a warning
if len(results.IPsChecked) == 0 {
checks = append(checks, api.Check{
Category: api.Blacklist,
Name: "RBL Check",
Status: api.CheckStatusWarn,
Score: 1.0,
Message: "No public IP addresses found to check",
Severity: api.PtrTo(api.Low),
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
})
return checks
}
// Create a summary check
summaryCheck := r.generateSummaryCheck(results)
checks = append(checks, summaryCheck)
// Create individual checks for each listing
for _, check := range results.Checks {
if check.Listed {
detailCheck := r.generateListingCheck(&check)
checks = append(checks, detailCheck)
}
}
return checks
}
// generateSummaryCheck creates an overall RBL summary check
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: "RBL Summary",
}
score := r.GetBlacklistScore(results)
check.Score = score
totalChecks := len(results.Checks)
listedCount := results.ListedCount
if listedCount == 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
check.Severity = api.PtrTo(api.Info)
check.Advice = api.PtrTo("Your sending IP has a good reputation")
} else if listedCount == 1 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
} else if listedCount <= 3 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.Critical)
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
}
// Add details about IPs checked
if len(results.IPsChecked) > 0 {
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
check.Details = &details
}
return check
}
// generateListingCheck creates a check for a specific RBL listing
func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
Status: api.CheckStatusFail,
Score: 0.0,
}
check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
// Determine severity based on which RBL
if strings.Contains(rblCheck.RBL, "spamhaus") {
check.Severity = api.PtrTo(api.Critical)
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
check.Advice = &advice
} else if strings.Contains(rblCheck.RBL, "spamcop") {
check.Severity = api.PtrTo(api.High)
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
check.Advice = &advice
} else {
check.Severity = api.PtrTo(api.High)
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
check.Advice = &advice
}
// Add response code details
if rblCheck.Response != "" {
details := fmt.Sprintf("Response: %s", rblCheck.Response)
check.Details = &details
}
return check
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
seenIPs := make(map[string]bool)
var listedIPs []string
for _, check := range results.Checks {
if check.Listed && !seenIPs[check.IP] {
listedIPs = append(listedIPs, check.IP)
seenIPs[check.IP] = true
}
}
return listedIPs
}
// GetRBLsForIP returns all RBLs that list a specific IP
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
var rbls []string
for _, check := range results.Checks {
if check.IP == ip && check.Listed {
rbls = append(rbls, check.RBL)
}
}
return rbls
}

View file

@ -1,348 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"time"
"git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
// ReportGenerator generates comprehensive deliverability reports
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *RBLChecker
contentAnalyzer *ContentAnalyzer
scorer *DeliverabilityScorer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
scorer: NewDeliverabilityScorer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
SpamAssassin *SpamAssassinResult
DNS *DNSResults
RBL *RBLResults
Content *ContentResults
Score *ScoringResult
}
// AnalyzeEmail performs complete email analysis
func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
results := &AnalysisResults{
Email: email,
}
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
results.RBL = r.rblChecker.CheckEmail(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
// Calculate overall score
results.Score = r.scorer.CalculateScore(
results.Authentication,
results.SpamAssassin,
results.RBL,
results.Content,
email,
)
return results
}
// GenerateReport creates a complete API report from analysis results
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
reportID := uuid.New()
now := time.Now()
report := &api.Report{
Id: reportID,
TestId: testID,
Score: results.Score.OverallScore,
CreatedAt: now,
}
// Build score summary
report.Summary = &api.ScoreSummary{
AuthenticationScore: results.Score.AuthScore,
SpamScore: results.Score.SpamScore,
BlacklistScore: results.Score.BlacklistScore,
ContentScore: results.Score.ContentScore,
HeaderScore: results.Score.HeaderScore,
}
// Collect all checks from different analyzers
checks := []api.Check{}
// Authentication checks
if results.Authentication != nil {
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
checks = append(checks, authChecks...)
}
// DNS checks
if results.DNS != nil {
dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
checks = append(checks, dnsChecks...)
}
// RBL checks
if results.RBL != nil {
rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
checks = append(checks, rblChecks...)
}
// SpamAssassin checks
if results.SpamAssassin != nil {
spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
checks = append(checks, spamChecks...)
}
// Content checks
if results.Content != nil {
contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
checks = append(checks, contentChecks...)
}
// Header checks
headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
checks = append(checks, headerChecks...)
report.Checks = checks
// Add authentication results
report.Authentication = results.Authentication
// Add SpamAssassin result
if results.SpamAssassin != nil {
report.Spamassassin = &api.SpamAssassinResult{
Score: float32(results.SpamAssassin.Score),
RequiredScore: float32(results.SpamAssassin.RequiredScore),
IsSpam: results.SpamAssassin.IsSpam,
}
if len(results.SpamAssassin.Tests) > 0 {
report.Spamassassin.Tests = &results.SpamAssassin.Tests
}
if results.SpamAssassin.RawReport != "" {
report.Spamassassin.Report = &results.SpamAssassin.RawReport
}
}
// Add DNS records
if results.DNS != nil {
dnsRecords := r.buildDNSRecords(results.DNS)
if len(dnsRecords) > 0 {
report.DnsRecords = &dnsRecords
}
}
// Add blacklist checks
if results.RBL != nil && len(results.RBL.Checks) > 0 {
blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks))
for _, check := range results.RBL.Checks {
blCheck := api.BlacklistCheck{
Ip: check.IP,
Rbl: check.RBL,
Listed: check.Listed,
}
if check.Response != "" {
blCheck.Response = &check.Response
}
blacklistChecks = append(blacklistChecks, blCheck)
}
report.Blacklists = &blacklistChecks
}
// Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders
}
return report
}
// buildDNSRecords converts DNS analysis results to API DNS records
func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord {
records := []api.DNSRecord{}
if dns == nil {
return records
}
// MX records
if len(dns.MXRecords) > 0 {
for _, mx := range dns.MXRecords {
status := api.Found
if !mx.Valid {
if mx.Error != "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.MX,
Status: status,
}
if mx.Host != "" {
value := mx.Host
record.Value = &value
}
records = append(records, record)
}
}
// SPF record
if dns.SPFRecord != nil {
status := api.Found
if !dns.SPFRecord.Valid {
if dns.SPFRecord.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.SPF,
Status: status,
}
if dns.SPFRecord.Record != "" {
record.Value = &dns.SPFRecord.Record
}
records = append(records, record)
}
// DKIM records
for _, dkim := range dns.DKIMRecords {
status := api.Found
if !dkim.Valid {
if dkim.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dkim.Domain,
RecordType: api.DKIM,
Status: status,
}
if dkim.Record != "" {
// Include selector in value for clarity
value := dkim.Record
record.Value = &value
}
records = append(records, record)
}
// DMARC record
if dns.DMARCRecord != nil {
status := api.Found
if !dns.DMARCRecord.Valid {
if dns.DMARCRecord.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.DMARC,
Status: status,
}
if dns.DMARCRecord.Record != "" {
record.Value = &dns.DMARCRecord.Record
}
records = append(records, record)
}
return records
}
// GenerateRawEmail returns the raw email message as a string
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
if email == nil {
return ""
}
raw := email.RawHeaders
if email.RawBody != "" {
raw += "\n" + email.RawBody
}
return raw
}
// GetRecommendations returns actionable recommendations based on the score
func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
if results == nil || results.Score == nil {
return []string{}
}
return results.Score.Recommendations
}
// GetScoreSummaryText returns a human-readable score summary
func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
if results == nil || results.Score == nil {
return ""
}
return r.scorer.GetScoreSummary(results.Score)
}

View file

@ -1,501 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
func TestNewReportGenerator(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
if gen.authAnalyzer == nil {
t.Error("authAnalyzer should not be nil")
}
if gen.spamAnalyzer == nil {
t.Error("spamAnalyzer should not be nil")
}
if gen.dnsAnalyzer == nil {
t.Error("dnsAnalyzer should not be nil")
}
if gen.rblChecker == nil {
t.Error("rblChecker should not be nil")
}
if gen.contentAnalyzer == nil {
t.Error("contentAnalyzer should not be nil")
}
if gen.scorer == nil {
t.Error("scorer should not be nil")
}
}
func TestAnalyzeEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
email := createTestEmail()
results := gen.AnalyzeEmail(email)
if results == nil {
t.Fatal("Expected analysis results, got nil")
}
if results.Email == nil {
t.Error("Email should not be nil")
}
if results.Authentication == nil {
t.Error("Authentication should not be nil")
}
// SpamAssassin might be nil if headers don't exist
// DNS results should exist
// RBL results should exist
// Content results should exist
if results.Score == nil {
t.Error("Score should not be nil")
}
// Verify score is within bounds
if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 {
t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
}
}
func TestGenerateReport(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createTestEmail()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
if report == nil {
t.Fatal("Expected report, got nil")
}
// Verify required fields
if report.Id == uuid.Nil {
t.Error("Report ID should not be empty")
}
if report.TestId != testID {
t.Errorf("TestId = %s, want %s", report.TestId, testID)
}
if report.Score < 0 || report.Score > 10 {
t.Errorf("Score %v is out of bounds", report.Score)
}
if report.Summary == nil {
t.Error("Summary should not be nil")
}
if len(report.Checks) == 0 {
t.Error("Checks should not be empty")
}
// Verify score summary
if report.Summary != nil {
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
}
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 {
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
}
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 {
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
}
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 {
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
}
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 {
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
}
}
// Verify checks have required fields
for i, check := range report.Checks {
if string(check.Category) == "" {
t.Errorf("Check %d: Category should not be empty", i)
}
if check.Name == "" {
t.Errorf("Check %d: Name should not be empty", i)
}
if string(check.Status) == "" {
t.Errorf("Check %d: Status should not be empty", i)
}
if check.Message == "" {
t.Errorf("Check %d: Message should not be empty", i)
}
}
}
func TestGenerateReportWithSpamAssassin(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
if report.Spamassassin == nil {
t.Error("SpamAssassin result should not be nil")
}
if report.Spamassassin != nil {
if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 {
t.Error("SpamAssassin scores should be set")
}
}
}
func TestBuildDNSRecords(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
dns *DNSResults
expectedCount int
expectTypes []api.DNSRecordRecordType
}{
{
name: "Nil DNS results",
dns: nil,
expectedCount: 0,
},
{
name: "Complete DNS results",
dns: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
SPFRecord: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
DKIMRecords: []DKIMRecord{
{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=...",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Valid: true,
},
},
expectedCount: 4, // MX, SPF, DKIM, DMARC
expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC},
},
{
name: "Missing records",
dns: &DNSResults{
Domain: "example.com",
SPFRecord: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
},
expectedCount: 1,
expectTypes: []api.DNSRecordRecordType{api.SPF},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
records := gen.buildDNSRecords(tt.dns)
if len(records) != tt.expectedCount {
t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount)
}
// Verify expected types are present
if tt.expectTypes != nil {
foundTypes := make(map[api.DNSRecordRecordType]bool)
for _, record := range records {
foundTypes[record.RecordType] = true
}
for _, expectedType := range tt.expectTypes {
if !foundTypes[expectedType] {
t.Errorf("Expected DNS record type %s not found", expectedType)
}
}
}
// Verify all records have required fields
for i, record := range records {
if record.Domain == "" {
t.Errorf("Record %d: Domain should not be empty", i)
}
if string(record.RecordType) == "" {
t.Errorf("Record %d: RecordType should not be empty", i)
}
if string(record.Status) == "" {
t.Errorf("Record %d: Status should not be empty", i)
}
}
})
}
}
func TestGenerateRawEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
email *EmailMessage
expected string
}{
{
name: "Nil email",
email: nil,
expected: "",
},
{
name: "Email with headers only",
email: &EmailMessage{
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n",
RawBody: "",
},
expected: "From: sender@example.com\nTo: recipient@example.com\n",
},
{
name: "Email with headers and body",
email: &EmailMessage{
RawHeaders: "From: sender@example.com\n",
RawBody: "This is the email body",
},
expected: "From: sender@example.com\n\nThis is the email body",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := gen.GenerateRawEmail(tt.email)
if raw != tt.expected {
t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected)
}
})
}
}
func TestGetRecommendations(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
results *AnalysisResults
expectCount int
}{
{
name: "Nil results",
results: nil,
expectCount: 0,
},
{
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 1.0,
BlacklistScore: 1.5,
ContentScore: 0.5,
HeaderScore: 0.5,
Recommendations: []string{
"Improve authentication",
"Fix content issues",
},
},
},
expectCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recs := gen.GetRecommendations(tt.results)
if len(recs) != tt.expectCount {
t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
}
})
}
}
func TestGetScoreSummaryText(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
results *AnalysisResults
expectEmpty bool
expectString string
}{
{
name: "Nil results",
results: nil,
expectEmpty: true,
},
{
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
},
},
expectEmpty: false,
expectString: "8.5/10",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := gen.GetScoreSummaryText(tt.results)
if tt.expectEmpty {
if summary != "" {
t.Errorf("Expected empty summary, got %q", summary)
}
} else {
if summary == "" {
t.Error("Expected non-empty summary")
}
if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
}
}
})
}
}
func TestReportCategories(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createComprehensiveTestEmail()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
// Verify all check categories are present
categories := make(map[api.CheckCategory]bool)
for _, check := range report.Checks {
categories[check.Category] = true
}
expectedCategories := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Headers,
}
for _, cat := range expectedCategories {
if !categories[cat] {
t.Errorf("Expected category %s not found in checks", cat)
}
}
}
// Helper functions
func createTestEmail() *EmailMessage {
header := make(mail.Header)
header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"}
header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"}
header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"}
header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"}
header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{"<test123@example.com>"}
return &EmailMessage{
Header: header,
From: &mail.Address{Address: "sender@example.com"},
To: []*mail.Address{{Address: "recipient@example.com"}},
Subject: "Test Email",
MessageID: "<test123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{
{
ContentType: "text/plain",
Content: "This is a test email",
IsText: true,
},
},
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: <test123@example.com>\n",
RawBody: "This is a test email",
}
}
func createTestEmailWithSpamAssassin() *EmailMessage {
email := createTestEmail()
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"}
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"}
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
return email
}
func createComprehensiveTestEmail() *EmailMessage {
email := createTestEmailWithSpamAssassin()
// Add authentication headers
email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
"example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
}
// Add HTML content
email.Parts = append(email.Parts, MessagePart{
ContentType: "text/html",
Content: "<html><body><p>Test</p><a href='https://example.com'>Link</a></body></html>",
IsHTML: true,
})
return email
}

View file

@ -1,506 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"fmt"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DeliverabilityScorer aggregates all analysis results and computes overall score
type DeliverabilityScorer struct{}
// NewDeliverabilityScorer creates a new deliverability scorer
func NewDeliverabilityScorer() *DeliverabilityScorer {
return &DeliverabilityScorer{}
}
// ScoringResult represents the complete scoring result
type ScoringResult struct {
OverallScore float32
Rating string // Excellent, Good, Fair, Poor, Critical
AuthScore float32
SpamScore float32
BlacklistScore float32
ContentScore float32
HeaderScore float32
Recommendations []string
CategoryBreakdown map[string]CategoryScore
}
// CategoryScore represents score breakdown for a category
type CategoryScore struct {
Score float32
MaxScore float32
Percentage float32
Status string // Pass, Warn, Fail
}
// CalculateScore computes the overall deliverability score from all analyzers
func (s *DeliverabilityScorer) CalculateScore(
authResults *api.AuthenticationResults,
spamResult *SpamAssassinResult,
rblResults *RBLResults,
contentResults *ContentResults,
email *EmailMessage,
) *ScoringResult {
result := &ScoringResult{
CategoryBreakdown: make(map[string]CategoryScore),
Recommendations: []string{},
}
// Calculate individual scores
authAnalyzer := NewAuthenticationAnalyzer()
result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults)
spamAnalyzer := NewSpamAssassinAnalyzer()
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs)
result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults)
contentAnalyzer := NewContentAnalyzer(10 * time.Second)
result.ContentScore = contentAnalyzer.GetContentScore(contentResults)
// Calculate header quality score
result.HeaderScore = s.calculateHeaderScore(email)
// Calculate overall score (out of 10)
result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
// Ensure score is within bounds
if result.OverallScore > 10.0 {
result.OverallScore = 10.0
}
if result.OverallScore < 0.0 {
result.OverallScore = 0.0
}
// Determine rating
result.Rating = s.determineRating(result.OverallScore)
// Build category breakdown
result.CategoryBreakdown["Authentication"] = CategoryScore{
Score: result.AuthScore,
MaxScore: 3.0,
Percentage: (result.AuthScore / 3.0) * 100,
Status: s.getCategoryStatus(result.AuthScore, 3.0),
}
result.CategoryBreakdown["Spam Filters"] = CategoryScore{
Score: result.SpamScore,
MaxScore: 2.0,
Percentage: (result.SpamScore / 2.0) * 100,
Status: s.getCategoryStatus(result.SpamScore, 2.0),
}
result.CategoryBreakdown["Blacklists"] = CategoryScore{
Score: result.BlacklistScore,
MaxScore: 2.0,
Percentage: (result.BlacklistScore / 2.0) * 100,
Status: s.getCategoryStatus(result.BlacklistScore, 2.0),
}
result.CategoryBreakdown["Content Quality"] = CategoryScore{
Score: result.ContentScore,
MaxScore: 2.0,
Percentage: (result.ContentScore / 2.0) * 100,
Status: s.getCategoryStatus(result.ContentScore, 2.0),
}
result.CategoryBreakdown["Email Structure"] = CategoryScore{
Score: result.HeaderScore,
MaxScore: 1.0,
Percentage: (result.HeaderScore / 1.0) * 100,
Status: s.getCategoryStatus(result.HeaderScore, 1.0),
}
// Generate recommendations
result.Recommendations = s.generateRecommendations(result)
return result
}
// calculateHeaderScore evaluates email structural quality (0-1 point)
func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 {
if email == nil {
return 0.0
}
score := float32(0.0)
requiredHeaders := 0
presentHeaders := 0
// Check required headers (RFC 5322)
headers := map[string]bool{
"From": false,
"Date": false,
"Message-ID": false,
}
for header := range headers {
requiredHeaders++
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
headers[header] = true
presentHeaders++
}
}
// Score based on required headers (0.4 points)
if presentHeaders == requiredHeaders {
score += 0.4
} else {
score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders))
}
// Check recommended headers (0.3 points)
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
recommendedPresent := 0
for _, header := range recommendedHeaders {
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
recommendedPresent++
}
}
score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))
// Check for proper MIME structure (0.2 points)
if len(email.Parts) > 0 {
score += 0.2
}
// Check Message-ID format (0.1 points)
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
if s.isValidMessageID(messageID) {
score += 0.1
}
}
// Ensure score doesn't exceed 1.0
if score > 1.0 {
score = 1.0
}
return score
}
// isValidMessageID checks if a Message-ID has proper format
func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool {
// Basic check: should be in format <...@...>
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
return false
}
// Remove angle brackets
messageID = strings.TrimPrefix(messageID, "<")
messageID = strings.TrimSuffix(messageID, ">")
// Should contain @ symbol
if !strings.Contains(messageID, "@") {
return false
}
parts := strings.Split(messageID, "@")
if len(parts) != 2 {
return false
}
// Both parts should be non-empty
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// determineRating determines the rating based on overall score
func (s *DeliverabilityScorer) determineRating(score float32) string {
switch {
case score >= 9.0:
return "Excellent"
case score >= 7.0:
return "Good"
case score >= 5.0:
return "Fair"
case score >= 3.0:
return "Poor"
default:
return "Critical"
}
}
// getCategoryStatus determines status for a category
func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
percentage := (score / maxScore) * 100
switch {
case percentage >= 80.0:
return "Pass"
case percentage >= 50.0:
return "Warn"
default:
return "Fail"
}
}
// generateRecommendations creates actionable recommendations based on scores
func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string {
var recommendations []string
// Authentication recommendations
if result.AuthScore < 2.0 {
recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records")
} else if result.AuthScore < 3.0 {
recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability")
}
// Spam recommendations
if result.SpamScore < 1.0 {
recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns")
} else if result.SpamScore < 1.5 {
recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues")
}
// Blacklist recommendations
if result.BlacklistScore < 1.0 {
recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation")
} else if result.BlacklistScore < 2.0 {
recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices")
}
// Content recommendations
if result.ContentScore < 1.0 {
recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure")
} else if result.ContentScore < 1.5 {
recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency")
}
// Header recommendations
if result.HeaderScore < 0.5 {
recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)")
} else if result.HeaderScore < 1.0 {
recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present")
}
// Overall recommendations based on rating
if result.Rating == "Excellent" {
recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices")
} else if result.Rating == "Critical" {
recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam")
}
return recommendations
}
// GenerateHeaderChecks creates checks for email header quality
func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
var checks []api.Check
if email == nil {
return checks
}
// Required headers check
checks = append(checks, s.generateRequiredHeadersCheck(email))
// Recommended headers check
checks = append(checks, s.generateRecommendedHeadersCheck(email))
// Message-ID check
checks = append(checks, s.generateMessageIDCheck(email))
// MIME structure check
checks = append(checks, s.generateMIMEStructureCheck(email))
return checks
}
// generateRequiredHeadersCheck checks for required RFC 5322 headers
func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Required Headers",
}
requiredHeaders := []string{"From", "Date", "Message-ID"}
missing := []string{}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Severity = api.PtrTo(api.Info)
check.Message = "All required headers are present"
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
} else {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Critical)
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
}
return check
}
// generateRecommendedHeadersCheck checks for recommended headers
func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Recommended Headers",
}
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
missing := []string{}
for _, header := range recommendedHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Message = "All recommended headers are present"
check.Advice = api.PtrTo("Your email includes all recommended headers")
} else if len(missing) < len(recommendedHeaders) {
check.Status = api.CheckStatusWarn
check.Score = 0.15
check.Severity = api.PtrTo(api.Low)
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
} else {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Message = "Missing all recommended headers"
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
}
return check
}
// generateMessageIDCheck validates Message-ID header
func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Message-ID Format",
}
messageID := email.GetHeaderValue("Message-ID")
if messageID == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.High)
check.Message = "Message-ID header is missing"
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
} else if !s.isValidMessageID(messageID) {
check.Status = api.CheckStatusWarn
check.Score = 0.05
check.Severity = api.PtrTo(api.Medium)
check.Message = "Message-ID format is invalid"
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
check.Details = &messageID
} else {
check.Status = api.CheckStatusPass
check.Score = 0.1
check.Severity = api.PtrTo(api.Info)
check.Message = "Message-ID is properly formatted"
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
check.Details = &messageID
}
return check
}
// generateMIMEStructureCheck validates MIME structure
func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "MIME Structure",
}
if len(email.Parts) == 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Low)
check.Message = "No MIME parts detected"
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.2
check.Severity = api.PtrTo(api.Info)
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
check.Advice = api.PtrTo("Your email has proper MIME structure")
// Add details about parts
partTypes := []string{}
for _, part := range email.Parts {
if part.ContentType != "" {
partTypes = append(partTypes, part.ContentType)
}
}
if len(partTypes) > 0 {
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
check.Details = &details
}
}
return check
}
// GetScoreSummary generates a human-readable summary of the score
func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
var summary strings.Builder
summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating))
summary.WriteString("Category Breakdown:\n")
summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n",
result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status))
summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n",
result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status))
summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n",
result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status))
summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n",
result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status))
summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n",
result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status))
if len(result.Recommendations) > 0 {
summary.WriteString("\nRecommendations:\n")
for _, rec := range result.Recommendations {
summary.WriteString(fmt.Sprintf(" %s\n", rec))
}
}
return summary.String()
}

View file

@ -1,762 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDeliverabilityScorer(t *testing.T) {
scorer := NewDeliverabilityScorer()
if scorer == nil {
t.Fatal("Expected scorer, got nil")
}
}
func TestIsValidMessageID(t *testing.T) {
tests := []struct {
name string
messageID string
expected bool
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expected: true,
},
{
name: "Valid with UUID",
messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>",
expected: true,
},
{
name: "Missing angle brackets",
messageID: "abc123@example.com",
expected: false,
},
{
name: "Missing @ symbol",
messageID: "<abc123example.com>",
expected: false,
},
{
name: "Multiple @ symbols",
messageID: "<abc@123@example.com>",
expected: false,
},
{
name: "Empty local part",
messageID: "<@example.com>",
expected: false,
},
{
name: "Empty domain part",
messageID: "<abc123@>",
expected: false,
},
{
name: "Empty",
messageID: "",
expected: false,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.isValidMessageID(tt.messageID)
if result != tt.expected {
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
}
})
}
}
func TestCalculateHeaderScore(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minScore float32
maxScore float32
}{
{
name: "Nil email",
email: nil,
minScore: 0.0,
maxScore: 0.0,
},
{
name: "Perfect headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.7,
maxScore: 1.0,
},
{
name: "Missing required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Subject": "Test",
}),
},
minScore: 0.0,
maxScore: 0.4,
},
{
name: "Required only, no recommended",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.4,
maxScore: 0.8,
},
{
name: "Invalid Message-ID format",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "invalid-message-id",
"Subject": "Test",
"To": "recipient@example.com",
"Reply-To": "reply@example.com",
}),
MessageID: "invalid-message-id",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.7,
maxScore: 1.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := scorer.calculateHeaderScore(tt.email)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestDetermineRating(t *testing.T) {
tests := []struct {
name string
score float32
expected string
}{
{name: "Excellent - 10.0", score: 10.0, expected: "Excellent"},
{name: "Excellent - 9.5", score: 9.5, expected: "Excellent"},
{name: "Excellent - 9.0", score: 9.0, expected: "Excellent"},
{name: "Good - 8.5", score: 8.5, expected: "Good"},
{name: "Good - 7.0", score: 7.0, expected: "Good"},
{name: "Fair - 6.5", score: 6.5, expected: "Fair"},
{name: "Fair - 5.0", score: 5.0, expected: "Fair"},
{name: "Poor - 4.5", score: 4.5, expected: "Poor"},
{name: "Poor - 3.0", score: 3.0, expected: "Poor"},
{name: "Critical - 2.5", score: 2.5, expected: "Critical"},
{name: "Critical - 0.0", score: 0.0, expected: "Critical"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.determineRating(tt.score)
if result != tt.expected {
t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected)
}
})
}
}
func TestGetCategoryStatus(t *testing.T) {
tests := []struct {
name string
score float32
maxScore float32
expected string
}{
{name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"},
{name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"},
{name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"},
{name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"},
{name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.getCategoryStatus(tt.score, tt.maxScore)
if result != tt.expected {
t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected)
}
})
}
}
func TestCalculateScore(t *testing.T) {
tests := []struct {
name string
authResults *api.AuthenticationResults
spamResult *SpamAssassinResult
rblResults *RBLResults
contentResults *ContentResults
email *EmailMessage
minScore float32
maxScore float32
expectedRating string
}{
{
name: "Perfect email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{Result: api.AuthResultResultPass},
},
spamResult: &SpamAssassinResult{
Score: -1.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 9.0,
maxScore: 10.0,
expectedRating: "Excellent",
},
{
name: "Poor email - auth issues",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultFail},
Dkim: &[]api.AuthResult{},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 8.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{
IP: "192.0.2.1",
RBL: "zen.spamhaus.org",
Listed: true,
},
},
ListedCount: 1,
},
contentResults: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
minScore: 0.0,
maxScore: 5.0,
expectedRating: "Poor",
},
{
name: "Average email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 4.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 6.0,
maxScore: 9.0,
expectedRating: "Good",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.CalculateScore(
tt.authResults,
tt.spamResult,
tt.rblResults,
tt.contentResults,
tt.email,
)
if result == nil {
t.Fatal("Expected result, got nil")
}
// Check overall score
if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore {
t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore)
}
// Check rating
if result.Rating != tt.expectedRating {
t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating)
}
// Verify score is within bounds
if result.OverallScore < 0.0 || result.OverallScore > 10.0 {
t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore)
}
// Verify category breakdown exists
if len(result.CategoryBreakdown) != 5 {
t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown))
}
// Verify recommendations exist
if len(result.Recommendations) == 0 && result.Rating != "Excellent" {
t.Error("Expected recommendations for non-excellent rating")
}
// Verify category scores add up to overall score
totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 {
t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)",
totalCategoryScore, result.OverallScore)
}
})
}
}
func TestGenerateRecommendations(t *testing.T) {
tests := []struct {
name string
result *ScoringResult
expectedMinCount int
shouldContainKeyword string
}{
{
name: "Excellent - minimal recommendations",
result: &ScoringResult{
OverallScore: 9.5,
Rating: "Excellent",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 2.0,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "Excellent",
},
{
name: "Critical - many recommendations",
result: &ScoringResult{
OverallScore: 1.0,
Rating: "Critical",
AuthScore: 0.5,
SpamScore: 0.0,
BlacklistScore: 0.0,
ContentScore: 0.3,
HeaderScore: 0.2,
},
expectedMinCount: 5,
shouldContainKeyword: "Critical",
},
{
name: "Poor authentication",
result: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "authentication",
},
{
name: "Blacklist issues",
result: &ScoringResult{
OverallScore: 4.0,
Rating: "Poor",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 0.5,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "blacklist",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recommendations := scorer.generateRecommendations(tt.result)
if len(recommendations) < tt.expectedMinCount {
t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount)
}
// Check if expected keyword appears in any recommendation
found := false
for _, rec := range recommendations {
if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) {
found = true
break
}
}
if !found {
t.Errorf("No recommendation contains keyword %q. Recommendations: %v",
tt.shouldContainKeyword, recommendations)
}
})
}
}
func TestGenerateRequiredHeadersCheck(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "All required headers present",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
From: &mail.Address{Address: "sender@example.com"},
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.4,
},
{
name: "Missing all required headers",
email: &EmailMessage{
Header: make(mail.Header),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "Missing some required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := scorer.generateRequiredHeadersCheck(tt.email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMessageIDCheck(t *testing.T) {
tests := []struct {
name string
messageID string
expectedStatus api.CheckStatus
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expectedStatus: api.CheckStatusPass,
},
{
name: "Invalid Message-ID format",
messageID: "invalid-message-id",
expectedStatus: api.CheckStatusWarn,
},
{
name: "Missing Message-ID",
messageID: "",
expectedStatus: api.CheckStatusFail,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Message-ID": tt.messageID,
}),
}
check := scorer.generateMessageIDCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMIMEStructureCheck(t *testing.T) {
tests := []struct {
name string
parts []MessagePart
expectedStatus api.CheckStatus
}{
{
name: "With MIME parts",
parts: []MessagePart{
{ContentType: "text/plain", Content: "test"},
{ContentType: "text/html", Content: "<p>test</p>"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No MIME parts",
parts: []MessagePart{},
expectedStatus: api.CheckStatusWarn,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
Parts: tt.parts,
}
check := scorer.generateMIMEStructureCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateHeaderChecks(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minChecks int
}{
{
name: "Nil email",
email: nil,
minChecks: 0,
},
{
name: "Complete email",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minChecks: 4, // Required, Recommended, Message-ID, MIME
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := scorer.GenerateHeaderChecks(tt.email)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Headers category
for _, check := range checks {
if check.Category != api.Headers {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
}
}
})
}
}
func TestGetScoreSummary(t *testing.T) {
result := &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
Recommendations: []string{
"Improve content quality",
"Add more headers",
},
}
scorer := NewDeliverabilityScorer()
summary := scorer.GetScoreSummary(result)
// Check that summary contains key information
if !strings.Contains(summary, "8.5") {
t.Error("Summary should contain overall score")
}
if !strings.Contains(summary, "Good") {
t.Error("Summary should contain rating")
}
if !strings.Contains(summary, "Authentication") {
t.Error("Summary should contain category names")
}
if !strings.Contains(summary, "Recommendations") {
t.Error("Summary should contain recommendations section")
}
}
// Helper function to create mail.Header with specific fields
func createHeaderWithFields(fields map[string]string) mail.Header {
header := make(mail.Header)
for key, value := range fields {
if value != "" {
// Use canonical MIME header key format
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
header[canonicalKey] = []string{value}
}
}
return header
}

View file

@ -1,340 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"fmt"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
type SpamAssassinAnalyzer struct{}
// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer
func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
return &SpamAssassinAnalyzer{}
}
// SpamAssassinResult represents parsed SpamAssassin results
type SpamAssassinResult struct {
IsSpam bool
Score float64
RequiredScore float64
Tests []string
TestDetails map[string]SpamTestDetail
Version string
RawReport string
}
// SpamTestDetail contains details about a specific spam test
type SpamTestDetail struct {
Name string
Score float64
Description string
}
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
// Parse X-Spam-Status header
if statusHeader, ok := headers["X-Spam-Status"]; ok {
a.parseSpamStatus(statusHeader, result)
}
// Parse X-Spam-Score header (as fallback if not in X-Spam-Status)
if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
result.Score = score
}
}
// Parse X-Spam-Flag header (as fallback)
if flagHeader, ok := headers["X-Spam-Flag"]; ok {
result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES"
}
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.RawReport = reportHeader
a.parseSpamReport(reportHeader, result)
}
// Parse X-Spam-Checker-Version
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
result.Version = strings.TrimSpace(versionHeader)
}
return result
}
// 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 *SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
firstPart := strings.TrimSpace(parts[0])
result.IsSpam = strings.EqualFold(firstPart, "yes")
}
// Extract score
scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.Score = score
}
}
// Extract required score
requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`)
if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 {
if required, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.RequiredScore = required
}
}
// Extract tests
testsRe := regexp.MustCompile(`tests=([^\s]+)`)
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
testsStr := matches[1]
// Tests can be comma or space separated
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
return r == ',' || r == ' '
})
result.Tests = tests
}
}
// parseSpamReport parses the X-Spam-Report header to extract test details
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
// Split by lines
lines := strings.Split(report, "\n")
// Regex to match test lines: * score TEST_NAME Description
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
matches := testRe.FindStringSubmatch(line)
if len(matches) > 3 {
testName := matches[2]
score, _ := strconv.ParseFloat(matches[1], 64)
description := strings.TrimSpace(matches[3])
detail := SpamTestDetail{
Name: testName,
Score: score,
Description: description,
}
result.TestDetails[testName] = detail
}
}
}
// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points)
// Scoring:
// - Score <= 0: 2 points (excellent)
// - Score < required: 1.5 points (good)
// - Score slightly above required (< 2x): 1 point (borderline)
// - Score moderately high (< 3x required): 0.5 points (poor)
// - Score very high: 0 points (spam)
func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 {
if result == nil {
return 0.0
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0 // Default SpamAssassin threshold
}
// Calculate deliverability score
if score <= 0 {
return 2.0
} else if score < required {
// Linear scaling from 1.5 to 2.0 based on how negative/low the score is
ratio := score / required
return 1.5 + (0.5 * (1.0 - float32(ratio)))
} else if score < required*2 {
// Slightly above threshold
return 1.0
} else if score < required*3 {
// Moderately high
return 0.5
}
// Very high spam score
return 0.0
}
// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
var checks []api.Check
if result == nil {
checks = append(checks, api.Check{
Category: api.Spam,
Name: "SpamAssassin Analysis",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No SpamAssassin headers found",
Severity: api.PtrTo(api.Medium),
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
})
return checks
}
// Main spam score check
mainCheck := a.generateMainSpamCheck(result)
checks = append(checks, mainCheck)
// Add checks for significant spam tests (score > 1.0 or < -1.0)
for _, test := range result.Tests {
if detail, ok := result.TestDetails[test]; ok {
if detail.Score > 1.0 || detail.Score < -1.0 {
check := a.generateTestCheck(detail)
checks = append(checks, check)
}
}
}
return checks
}
// generateMainSpamCheck creates the main spam score check
func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
check := api.Check{
Category: api.Spam,
Name: "SpamAssassin Score",
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0
}
delivScore := a.GetSpamAssassinScore(result)
check.Score = delivScore
// Determine status and message based on score
if score <= 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.Info)
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
} else if score < required {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.Info)
check.Advice = api.PtrTo("Your email passes spam filters")
} else if score < required*1.5 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.Medium)
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
} else if score < required*2 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.High)
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.Critical)
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
}
// Add details
if len(result.Tests) > 0 {
details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
if len(result.Tests) > 5 {
details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
}
check.Details = &details
}
return check
}
// generateTestCheck creates a check for a specific spam test
func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
check := api.Check{
Category: api.Spam,
Name: fmt.Sprintf("Spam Test: %s", detail.Name),
}
if detail.Score > 0 {
// Negative indicator (increases spam score)
if detail.Score > 2.0 {
check.Status = api.CheckStatusFail
check.Severity = api.PtrTo(api.High)
} else {
check.Status = api.CheckStatusWarn
check.Severity = api.PtrTo(api.Medium)
}
check.Score = 0.0
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
check.Advice = &advice
} else {
// Positive indicator (decreases spam score)
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.Info)
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
check.Advice = &advice
}
check.Details = &detail.Description
return check
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -32,20 +32,32 @@ import (
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version"
)
// EmailAnalyzer defines the interface for email analysis
// 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, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
}
// APIHandler implements the ServerInterface for handling API requests
type APIHandler struct {
storage storage.Storage
config *config.Config
analyzer EmailAnalyzer
startTime time.Time
}
// NewAPIHandler creates a new API handler
func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler {
func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler {
return &APIHandler{
storage: store,
config: cfg,
analyzer: analyzer,
startTime: time.Now(),
}
}
@ -56,27 +68,41 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
// Generate test email address
// Convert UUID to base32 string for the API response
base32ID := utils.UUIDToBase32(testID)
// Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
testID.String(),
base32ID,
h.config.Email.Domain,
)
// Return response
c.JSON(http.StatusCreated, TestResponse{
Id: testID,
Id: base32ID,
Email: openapi_types.Email(email),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the address above"),
Message: stringPtr("Send your test email to the given address"),
})
}
// GetTest retrieves test metadata
// (GET /test/{id})
func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
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{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(id)
reportExists, err := h.storage.ReportExists(testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
@ -94,29 +120,35 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
apiStatus = TestStatusPending
}
// Generate test email address
// Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
id.String(),
id,
h.config.Email.Domain,
)
// Return current time for CreatedAt/UpdatedAt since we don't track tests anymore
now := time.Now()
c.JSON(http.StatusOK, Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
CreatedAt: now,
UpdatedAt: &now,
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
})
}
// GetReport retrieves the detailed analysis report
// (GET /report/{id})
func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
reportJSON, _, err := h.storage.GetReport(id)
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{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
@ -139,8 +171,19 @@ func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
_, rawEmail, err := h.storage.GetReport(id)
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{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
@ -160,6 +203,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
c.Data(http.StatusOK, "text/plain", rawEmail)
}
// ReanalyzeReport re-analyzes an existing email and regenerates the report
// (POST /report/{id}/reanalyze)
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{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
// Retrieve the existing report (mainly to get the raw email)
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve email",
Details: stringPtr(err.Error()),
})
return
}
// Re-analyze the email using the current analyzer
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "analysis_error",
Message: "Failed to re-analyze email",
Details: stringPtr(err.Error()),
})
return
}
// Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to update report",
Details: stringPtr(err.Error()),
})
return
}
// Return the updated report JSON directly
c.Data(http.StatusOK, "application/json", reportJSON)
}
// GetStatus retrieves service health status
// (GET /status)
func (h *APIHandler) GetStatus(c *gin.Context) {
@ -181,7 +281,7 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
Status: overallStatus,
Version: "0.1.0-dev",
Version: version.Version,
Components: &struct {
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
@ -192,3 +292,92 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
Uptime: &uptime,
})
}
// TestDomain performs synchronous domain analysis
// (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) {
var request DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
})
return
}
// Perform domain analysis
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
var responseGrade DomainTestResponseGrade
switch grade {
case "A+":
responseGrade = DomainTestResponseGradeA
case "A":
responseGrade = DomainTestResponseGradeA1
case "B":
responseGrade = DomainTestResponseGradeB
case "C":
responseGrade = DomainTestResponseGradeC
case "D":
responseGrade = DomainTestResponseGradeD
case "E":
responseGrade = DomainTestResponseGradeE
case "F":
responseGrade = DomainTestResponseGradeF
default:
responseGrade = DomainTestResponseGradeF
}
// Build response
response := DomainTestResponse{
Domain: request.Domain,
Score: score,
Grade: responseGrade,
DnsResults: *dnsResults,
}
c.JSON(http.StatusOK, response)
}
// CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
var request BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
})
return
}
// Perform blacklist check using analyzer
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_ip",
Message: "Invalid IP address",
Details: stringPtr(err.Error()),
})
return
}
// Build response
response := BlacklistCheckResponse{
Ip: request.Ip,
Blacklists: checks,
Whitelists: &whitelists,
ListedCount: listedCount,
Score: score,
Grade: BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
}

View file

@ -31,9 +31,8 @@ import (
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/analyzer"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
// RunAnalyzer runs the standalone email analyzer (from stdin)
@ -87,57 +86,549 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
// outputHumanReadable outputs a human-readable summary
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
// Header
report := result.Report
// Header with overall score
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
fmt.Fprintln(writer, strings.Repeat("=", 70))
fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade)
fmt.Fprintf(writer, "Test ID: %s\n", report.TestId)
fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST"))
// Score summary
summary := emailAnalyzer.GetScoreSummaryText(result)
fmt.Fprintln(writer, summary)
// Score Summary
if report.Summary != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "SCORE BREAKDOWN")
fmt.Fprintln(writer, strings.Repeat("-", 70))
// Detailed checks
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
// Group checks by category
categories := make(map[api.CheckCategory][]api.Check)
for _, check := range result.Report.Checks {
categories[check.Category] = append(categories[check.Category], check)
summary := report.Summary
fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n",
summary.DnsScore, summary.DnsGrade)
fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n",
summary.AuthenticationScore, summary.AuthenticationGrade)
fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n",
summary.BlacklistScore, summary.BlacklistGrade)
fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n",
summary.HeaderScore, summary.HeaderGrade)
fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n",
summary.SpamScore, summary.SpamGrade)
fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n",
summary.ContentScore, summary.ContentGrade)
}
// Print checks by category
categoryOrder := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Blacklist,
api.Content,
api.Headers,
}
// DNS Results
if report.DnsResults != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "DNS CONFIGURATION")
fmt.Fprintln(writer, strings.Repeat("-", 70))
for _, category := range categoryOrder {
checks, ok := categories[category]
if !ok || len(checks) == 0 {
continue
dns := report.DnsResults
fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain)
if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain {
fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain)
}
fmt.Fprintf(writer, "\n%s:\n", category)
for _, check := range checks {
statusSymbol := "✓"
if check.Status == api.CheckStatusFail {
statusSymbol = "✗"
} else if check.Status == api.CheckStatusWarn {
statusSymbol = "⚠"
// MX Records
if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 {
fmt.Fprintln(writer, "\n MX Records (From Domain):")
for _, mx := range *dns.FromMxRecords {
status := "✓"
if !mx.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host)
if mx.Error != nil {
fmt.Fprintf(writer, " - ERROR: %s", *mx.Error)
}
fmt.Fprintln(writer)
}
}
fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
if check.Advice != nil && *check.Advice != "" {
fmt.Fprintf(writer, " → %s\n", *check.Advice)
// SPF Records
if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 {
fmt.Fprintln(writer, "\n SPF Records:")
for _, spf := range *dns.SpfRecords {
status := "✓"
if !spf.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s ", status)
if spf.Domain != nil {
fmt.Fprintf(writer, "Domain: %s", *spf.Domain)
}
if spf.AllQualifier != nil {
fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier)
}
fmt.Fprintln(writer)
if spf.Record != nil {
fmt.Fprintf(writer, " %s\n", *spf.Record)
}
if spf.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error)
}
}
}
// DKIM Records
if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 {
fmt.Fprintln(writer, "\n DKIM Records:")
for _, dkim := range *dns.DkimRecords {
status := "✓"
if !dkim.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain)
if dkim.Record != nil {
fmt.Fprintf(writer, " %s\n", *dkim.Record)
}
if dkim.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error)
}
}
}
// DMARC Record
if dns.DmarcRecord != nil {
fmt.Fprintln(writer, "\n DMARC Record:")
status := "✓"
if !dns.DmarcRecord.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid)
if dns.DmarcRecord.Policy != nil {
fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy)
}
if dns.DmarcRecord.SubdomainPolicy != nil {
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
}
fmt.Fprintln(writer)
if dns.DmarcRecord.Record != nil {
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
}
if dns.DmarcRecord.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error)
}
}
// BIMI Record
if dns.BimiRecord != nil {
fmt.Fprintln(writer, "\n BIMI Record:")
status := "✓"
if !dns.BimiRecord.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n",
status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain)
if dns.BimiRecord.LogoUrl != nil {
fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl)
}
if dns.BimiRecord.VmcUrl != nil {
fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl)
}
if dns.BimiRecord.Record != nil {
fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record)
}
if dns.BimiRecord.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error)
}
}
// PTR Records
if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 {
fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:")
for _, ptr := range *dns.PtrRecords {
fmt.Fprintf(writer, " %s\n", ptr)
}
}
// DNS Errors
if dns.Errors != nil && len(*dns.Errors) > 0 {
fmt.Fprintln(writer, "\n DNS Errors:")
for _, err := range *dns.Errors {
fmt.Fprintf(writer, " ! %s\n", err)
}
}
}
// Authentication Results
if report.Authentication != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "AUTHENTICATION RESULTS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
auth := report.Authentication
// SPF
if auth.Spf != nil {
fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result)))
if auth.Spf.Domain != nil {
fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain)
}
if auth.Spf.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details)
}
fmt.Fprintln(writer)
}
// DKIM
if auth.Dkim != nil && len(*auth.Dkim) > 0 {
fmt.Fprintln(writer, "\n DKIM:")
for i, dkim := range *auth.Dkim {
fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result)))
if dkim.Domain != nil {
fmt.Fprintf(writer, " (domain: %s", *dkim.Domain)
if dkim.Selector != nil {
fmt.Fprintf(writer, ", selector: %s", *dkim.Selector)
}
fmt.Fprintf(writer, ")")
}
if dkim.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *dkim.Details)
}
fmt.Fprintln(writer)
}
}
// DMARC
if auth.Dmarc != nil {
fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result)))
if auth.Dmarc.Domain != nil {
fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain)
}
if auth.Dmarc.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details)
}
fmt.Fprintln(writer)
}
// ARC
if auth.Arc != nil {
fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result)))
if auth.Arc.ChainLength != nil {
fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength)
}
if auth.Arc.ChainValid != nil {
fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid)
}
if auth.Arc.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details)
}
fmt.Fprintln(writer)
}
// BIMI
if auth.Bimi != nil {
fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result)))
if auth.Bimi.Domain != nil {
fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain)
}
if auth.Bimi.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details)
}
fmt.Fprintln(writer)
}
// IP Reverse
if auth.Iprev != nil {
fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result)))
if auth.Iprev.Ip != nil {
fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip)
if auth.Iprev.Hostname != nil {
fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname)
}
fmt.Fprintf(writer, ")")
}
if auth.Iprev.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details)
}
fmt.Fprintln(writer)
}
}
// Blacklist Results
if report.Blacklists != nil && len(*report.Blacklists) > 0 {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "BLACKLIST CHECKS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
totalChecks := 0
totalListed := 0
for ip, checks := range *report.Blacklists {
totalChecks += len(checks)
fmt.Fprintf(writer, "\n IP Address: %s\n", ip)
for _, check := range checks {
status := "✓"
if check.Listed {
status = "✗"
totalListed++
}
fmt.Fprintf(writer, " %s %s", status, check.Rbl)
if check.Listed {
fmt.Fprintf(writer, " - LISTED")
if check.Response != nil {
fmt.Fprintf(writer, " (%s)", *check.Response)
}
} else {
fmt.Fprintf(writer, " - OK")
}
fmt.Fprintln(writer)
if check.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *check.Error)
}
}
}
fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks)
}
// Header Analysis
if report.HeaderAnalysis != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "HEADER ANALYSIS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
header := report.HeaderAnalysis
// Domain Alignment
if header.DomainAlignment != nil {
fmt.Fprintln(writer, "\n Domain Alignment:")
align := header.DomainAlignment
if align.FromDomain != nil {
fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain)
if align.FromOrgDomain != nil {
fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain)
}
fmt.Fprintln(writer)
}
if align.ReturnPathDomain != nil {
fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain)
if align.ReturnPathOrgDomain != nil {
fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain)
}
fmt.Fprintln(writer)
}
if align.Aligned != nil {
fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned)
}
if align.RelaxedAligned != nil {
fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned)
}
if align.Issues != nil && len(*align.Issues) > 0 {
fmt.Fprintln(writer, " Issues:")
for _, issue := range *align.Issues {
fmt.Fprintf(writer, " - %s\n", issue)
}
}
}
// Required/Important Headers
if header.Headers != nil {
fmt.Fprintln(writer, "\n Standard Headers:")
importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"}
for _, hdrName := range importantHeaders {
if hdr, ok := (*header.Headers)[hdrName]; ok {
status := "✗"
if hdr.Present {
status = "✓"
}
fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName))
if hdr.Present {
if hdr.Valid != nil && !*hdr.Valid {
fmt.Fprintf(writer, "INVALID")
} else {
fmt.Fprintf(writer, "OK")
}
if hdr.Importance != nil {
fmt.Fprintf(writer, " [%s]", *hdr.Importance)
}
} else {
fmt.Fprintf(writer, "MISSING")
}
fmt.Fprintln(writer)
if hdr.Issues != nil && len(*hdr.Issues) > 0 {
for _, issue := range *hdr.Issues {
fmt.Fprintf(writer, " - %s\n", issue)
}
}
}
}
}
// Header Issues
if header.Issues != nil && len(*header.Issues) > 0 {
fmt.Fprintln(writer, "\n Header Issues:")
for _, issue := range *header.Issues {
fmt.Fprintf(writer, " [%s] %s: %s\n",
strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message)
if issue.Advice != nil {
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
}
}
}
// Received Chain
if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 {
fmt.Fprintln(writer, "\n Email Path (Received Chain):")
for i, hop := range *header.ReceivedChain {
fmt.Fprintf(writer, " [%d] ", i+1)
if hop.From != nil {
fmt.Fprintf(writer, "%s", *hop.From)
if hop.Ip != nil {
fmt.Fprintf(writer, " (%s)", *hop.Ip)
}
}
if hop.By != nil {
fmt.Fprintf(writer, " -> %s", *hop.By)
}
fmt.Fprintln(writer)
if hop.Timestamp != nil {
fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST"))
}
}
}
}
// SpamAssassin Results
if report.Spamassassin != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
sa := report.Spamassassin
fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore)
if sa.IsSpam {
fmt.Fprintf(writer, " (SPAM)")
} else {
fmt.Fprintf(writer, " (HAM)")
}
fmt.Fprintln(writer)
if sa.Version != nil {
fmt.Fprintf(writer, " Version: %s\n", *sa.Version)
}
if len(sa.TestDetails) > 0 {
fmt.Fprintln(writer, "\n Triggered Tests:")
for _, test := range sa.TestDetails {
scoreStr := "+"
if test.Score < 0 {
scoreStr = ""
}
fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name)
if test.Description != nil {
fmt.Fprintf(writer, "\n %s", *test.Description)
}
fmt.Fprintln(writer)
}
}
}
// Content Analysis
if report.ContentAnalysis != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "CONTENT ANALYSIS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
content := report.ContentAnalysis
// Basic content info
fmt.Fprintln(writer, "\n Content Structure:")
if content.HasPlaintext != nil {
fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext)
}
if content.HasHtml != nil {
fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml)
}
if content.TextToImageRatio != nil {
fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio)
}
// Unsubscribe
if content.HasUnsubscribeLink != nil {
fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink)
if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 {
fmt.Fprintf(writer, " Unsubscribe Methods: ")
for i, method := range *content.UnsubscribeMethods {
if i > 0 {
fmt.Fprintf(writer, ", ")
}
fmt.Fprintf(writer, "%s", method)
}
fmt.Fprintln(writer)
}
}
// Links
if content.Links != nil && len(*content.Links) > 0 {
fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links))
for _, link := range *content.Links {
status := ""
switch link.Status {
case "valid":
status = "✓"
case "broken":
status = "✗"
case "suspicious":
status = "⚠"
case "redirected":
status = "→"
case "timeout":
status = "⏱"
}
fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url)
if link.HttpCode != nil {
fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode)
}
fmt.Fprintln(writer)
if link.RedirectChain != nil && len(*link.RedirectChain) > 0 {
fmt.Fprintln(writer, " Redirect chain:")
for _, url := range *link.RedirectChain {
fmt.Fprintf(writer, " -> %s\n", url)
}
}
}
}
// Images
if content.Images != nil && len(*content.Images) > 0 {
fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images))
missingAlt := 0
trackingPixels := 0
for _, img := range *content.Images {
if !img.HasAlt {
missingAlt++
}
if img.IsTrackingPixel != nil && *img.IsTrackingPixel {
trackingPixels++
}
}
fmt.Fprintf(writer, " Images with ALT text: %d/%d\n",
len(*content.Images)-missingAlt, len(*content.Images))
if trackingPixels > 0 {
fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels)
}
}
// HTML Issues
if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 {
fmt.Fprintln(writer, "\n Content Issues:")
for _, issue := range *content.HtmlIssues {
fmt.Fprintf(writer, " [%s] %s: %s\n",
strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message)
if issue.Location != nil {
fmt.Fprintf(writer, " Location: %s\n", *issue.Location)
}
if issue.Advice != nil {
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
}
}
}
}
// Footer
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n")
fmt.Fprintln(writer, strings.Repeat("=", 70))
return nil
}

156
internal/app/cli_backup.go Normal file
View file

@ -0,0 +1,156 @@
// 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 app
import (
"encoding/json"
"fmt"
"io"
"os"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
)
// BackupData represents the structure of a backup file
type BackupData struct {
Version string `json:"version"`
Reports []storage.Report `json:"reports"`
}
// RunBackup exports the database to stdout as JSON
func RunBackup(cfg *config.Config) error {
if err := cfg.Validate(); err != nil {
return err
}
// Initialize storage
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer store.Close()
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
// Get all reports from the database
reports, err := storage.GetAllReports(store)
if err != nil {
return fmt.Errorf("failed to retrieve reports: %w", err)
}
fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports))
// Create backup data structure
backup := BackupData{
Version: "1.0",
Reports: reports,
}
// Encode to JSON and write to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(backup); err != nil {
return fmt.Errorf("failed to encode backup data: %w", err)
}
return nil
}
// RunRestore imports the database from a JSON file or stdin
func RunRestore(cfg *config.Config, inputPath string) error {
if err := cfg.Validate(); err != nil {
return err
}
// Determine input source
var reader io.Reader
if inputPath == "" || inputPath == "-" {
fmt.Fprintln(os.Stderr, "Reading backup from stdin...")
reader = os.Stdin
} else {
inFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open backup file: %w", err)
}
defer inFile.Close()
fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath)
reader = inFile
}
// Decode JSON
var backup BackupData
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&backup); err != nil {
if err == io.EOF {
return fmt.Errorf("backup file is empty or corrupted")
}
return fmt.Errorf("failed to decode backup data: %w", err)
}
fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version)
fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports))
// Initialize storage
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer store.Close()
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
// Restore reports
restored, skipped, failed := 0, 0, 0
for _, report := range backup.Reports {
// Check if report already exists
exists, err := store.ReportExists(report.TestID)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err)
failed++
continue
}
if exists {
fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID)
skipped++
continue
}
// Create the report
_, err = storage.CreateReportFromBackup(store, &report)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err)
failed++
continue
}
restored++
}
fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed)
if failed > 0 {
return fmt.Errorf("restore completed with %d failures", failed)
}
return nil
}

View file

@ -25,13 +25,16 @@ import (
"context"
"log"
"os"
"time"
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/lmtp"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/pkg/analyzer"
"git.happydns.org/happyDeliver/web"
)
@ -63,8 +66,11 @@ func RunServer(cfg *config.Config) error {
}
}()
// Create analyzer adapter for API
analyzerAdapter := analyzer.NewAPIAdapter(cfg)
// Create API handler
handler := api.NewAPIHandler(store, cfg)
handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
// Set up Gin router
if os.Getenv("GIN_MODE") == "" {
@ -72,8 +78,30 @@ func RunServer(cfg *config.Config) error {
}
router := gin.Default()
// Register API routes
apiGroup := router.Group("/api")
if cfg.RateLimit > 0 {
// Set up rate limiting (2x to handle burst)
rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: 2 * time.Second,
Limit: 2 * cfg.RateLimit,
})
rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
c.JSON(429, gin.H{
"error": "rate_limit_exceeded",
"message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(),
})
},
KeyFunc: func(c *gin.Context) string {
return c.ClientIP()
},
})
apiGroup.Use(rateLimiter)
}
// Register API routes
api.RegisterHandlers(apiGroup, handler)
web.DeclareRoutes(cfg, router)

View file

@ -37,7 +37,11 @@ func declareFlags(o *Config) {
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.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")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -25,6 +25,7 @@ import (
"flag"
"fmt"
"log"
"net/url"
"os"
"path"
"strings"
@ -41,6 +42,9 @@ type Config struct {
Email EmailConfig
Analysis AnalysisConfig
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
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
}
// DatabaseConfig contains database connection settings
@ -58,17 +62,20 @@ type EmailConfig struct {
// AnalysisConfig contains timeout and behavior settings for email analysis
type AnalysisConfig struct {
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
}
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
DevProxy: "",
Bind: ":8081",
Bind: ":8080",
ReportRetention: 0, // Keep reports forever by default
RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default)
Database: DatabaseConfig{
Type: "sqlite",
DSN: "happydeliver.db",
@ -82,6 +89,8 @@ func DefaultConfig() *Config {
DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second,
RBLs: []string{},
DNSWLs: []string{},
CheckAllIPs: false, // By default, only check the first IP
},
}
}
@ -113,7 +122,7 @@ func ConsolidateConfig() (opts *Config, err error) {
// If config file exists, read configuration from it
for _, filename := range configLocations {
if _, e := os.Stat(filename); !os.IsNotExist(e) {
if _, e := os.Stat(filename); !os.IsNotExist(e) && !os.IsPermission(e) {
log.Printf("Loading configuration from %s\n", filename)
err = parseFile(opts, filename)
if err != nil {

View file

@ -23,6 +23,7 @@ package config
import (
"fmt"
"net/url"
"strings"
)
@ -43,3 +44,25 @@ func (i *StringArray) Set(value string) error {
return nil
}
type URL struct {
URL *url.URL
}
func (i *URL) String() string {
if i.URL != nil {
return i.URL.String()
} else {
return ""
}
}
func (i *URL) Set(value string) error {
u, err := url.Parse(value)
if err != nil {
return err
}
*i.URL = *u
return nil
}

View file

@ -92,6 +92,10 @@ func (s *Session) Data(r io.Reader) error {
log.Printf("LMTP: Received %d bytes", len(emailData))
// Prepend Return-Path header from envelope sender
returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from)
emailData = append([]byte(returnPath), emailData...)
// Process email for each recipient
// LMTP requires per-recipient status, but go-smtp handles this internally
for _, recipient := range s.recipients {

View file

@ -22,6 +22,7 @@
package receiver
import (
"encoding/base32"
"encoding/json"
"fmt"
"io"
@ -31,9 +32,9 @@ import (
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/analyzer"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
// EmailReceiver handles incoming emails from the MTA
@ -95,7 +96,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return fmt.Errorf("failed to analyze email: %w", err)
}
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
@ -112,8 +113,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return nil
}
// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
// Hyphens are ignored during decoding
func base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens for decoding
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for Base32 decoding
encoded = strings.ToUpper(encoded)
// Decode from Base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
}
// Ensure we have exactly 16 bytes for UUID
if len(decoded) != 16 {
return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
}
// Convert bytes to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}
// extractTestID extracts the UUID from the test email address
// Expected format: test-<uuid>@domain.com
// Expected format: test-<base32-uuid>@domain.com
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
email = strings.Trim(email, "<>")
@ -133,10 +160,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
// Parse UUID
testID, err := uuid.Parse(uuidStr)
// Decode Base32 to UUID
testID, err := base32ToUUID(uuidStr)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
}
return testID, nil

View file

@ -43,6 +43,7 @@ type Storage interface {
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
ReportExists(testID uuid.UUID) (bool, error)
UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error)
// Close closes the database connection
@ -107,7 +108,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
var dbReport Report
if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil {
if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrNotFound
}
@ -117,6 +118,18 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
return dbReport.ReportJSON, dbReport.RawEmail, nil
}
// UpdateReport updates the report JSON for an existing test ID
func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error {
result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON)
if result.Error != nil {
return fmt.Errorf("failed to update report: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// DeleteOldReports deletes reports older than the specified time
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
@ -134,3 +147,33 @@ func (s *DBStorage) Close() error {
}
return sqlDB.Close()
}
// GetAllReports retrieves all reports from the database
func GetAllReports(s Storage) ([]Report, error) {
dbStorage, ok := s.(*DBStorage)
if !ok {
return nil, fmt.Errorf("storage type does not support GetAllReports")
}
var reports []Report
if err := dbStorage.db.Find(&reports).Error; err != nil {
return nil, fmt.Errorf("failed to retrieve reports: %w", err)
}
return reports, nil
}
// CreateReportFromBackup creates a report from backup data, preserving timestamps
func CreateReportFromBackup(s Storage, report *Report) (*Report, error) {
dbStorage, ok := s.(*DBStorage)
if !ok {
return nil, fmt.Errorf("storage type does not support CreateReportFromBackup")
}
// Use Create to insert the report with all fields including timestamps
if err := dbStorage.db.Create(report).Error; err != nil {
return nil, fmt.Errorf("failed to create report from backup: %w", err)
}
return report, nil
}

75
internal/utils/uuid.go Normal file
View file

@ -0,0 +1,75 @@
// 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 utils
import (
"encoding/base32"
"fmt"
"strings"
"github.com/google/uuid"
)
// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding)
// with hyphens every 7 characters for better readability
func UUIDToBase32(id uuid.UUID) string {
// Use RFC 4648 Base32 encoding (URL-safe)
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:])
// Convert to lowercase for better readability
encoded = strings.ToLower(encoded)
// Insert hyphens every 7 characters
var result strings.Builder
for i, char := range encoded {
if i > 0 && i%7 == 0 {
result.WriteRune('-')
}
result.WriteRune(char)
}
return result.String()
}
// Base32ToUUID converts a base32-encoded string back to a UUID
// Accepts strings with or without hyphens
func Base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for decoding
encoded = strings.ToUpper(encoded)
// Decode base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err)
}
// Ensure we have exactly 16 bytes for a UUID
if len(decoded) != 16 {
return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded))
}
// Convert byte slice to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}

View file

@ -0,0 +1,26 @@
// 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 version
// Version is the application version. It can be set at build time using ldflags:
// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3"
var Version = "(custom build)"

View file

@ -23,6 +23,7 @@ package analyzer
import (
"bytes"
"encoding/json"
"fmt"
"github.com/google/uuid"
@ -43,6 +44,8 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
)
return &EmailAnalyzer{
@ -78,10 +81,68 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A
}, nil
}
// GetScoreSummaryText returns a human-readable score summary
func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
if result == nil || result.Results == nil {
return ""
}
return a.generator.GetScoreSummaryText(result.Results)
// APIAdapter adapts the EmailAnalyzer to work with the API package
// This adapter implements the interface expected by the API handler
type APIAdapter struct {
analyzer *EmailAnalyzer
}
// NewAPIAdapter creates a new API adapter for the email analyzer
func NewAPIAdapter(cfg *config.Config) *APIAdapter {
return &APIAdapter{
analyzer: NewEmailAnalyzer(cfg),
}
}
// AnalyzeEmailBytes performs analysis and returns JSON bytes directly
func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) {
result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID)
if err != nil {
return nil, err
}
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
if err != nil {
return nil, fmt.Errorf("failed to marshal report: %w", err)
}
return reportJSON, nil
}
// AnalyzeDomain performs DNS analysis for a domain and returns the results
func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
// Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
// Calculate score
score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults)
return dnsResults, score, grade
}
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.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, 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},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
// 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

@ -0,0 +1,180 @@
// 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"
"git.happydns.org/happyDeliver/internal/api"
)
// AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct{}
// NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &api.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
// If no Authentication-Results headers, try to parse legacy headers
if results.Spf == nil {
results.Spf = a.parseLegacySPF(email)
}
// Parse ARC headers if not already parsed from Authentication-Results
if results.Arc == nil {
results.Arc = a.parseARCHeaders(email)
} else {
// Enhance the ARC result with chain information from raw headers
a.enhanceARCResult(email, results.Arc)
}
return results
}
// 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) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
return
}
// Skip the authserv-id (first part)
for i := 1; i < len(parts); i++ {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
// Parse SPF
if strings.HasPrefix(part, "spf=") {
if results.Spf == nil {
results.Spf = a.parseSPFResult(part)
}
}
// Parse DKIM
if strings.HasPrefix(part, "dkim=") {
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
}
}
}
// Parse DMARC
if strings.HasPrefix(part, "dmarc=") {
if results.Dmarc == nil {
results.Dmarc = a.parseDMARCResult(part)
}
}
// Parse BIMI
if strings.HasPrefix(part, "bimi=") {
if results.Bimi == nil {
results.Bimi = a.parseBIMIResult(part)
}
}
// Parse ARC
if strings.HasPrefix(part, "arc=") {
if results.Arc == nil {
results.Arc = a.parseARCResult(part)
}
}
// Parse IPRev
if strings.HasPrefix(part, "iprev=") {
if results.Iprev == nil {
results.Iprev = a.parseIPRevResult(part)
}
}
// Parse x-google-dkim
if strings.HasPrefix(part, "x-google-dkim=") {
if results.XGoogleDkim == nil {
results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
}
}
// Parse x-aligned-from
if strings.HasPrefix(part, "x-aligned-from=") {
if results.XAlignedFrom == nil {
results.XAlignedFrom = a.parseXAlignedFromResult(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) {
if results == nil {
return 0, ""
}
score := 0
// IPRev (15 points)
score += 15 * a.calculateIPRevScore(results) / 100
// SPF (25 points)
score += 25 * a.calculateSPFScore(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
// BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100
// Ensure score doesn't exceed 100
if score > 100 {
score = 100
}
return score, ScoreToGrade(score)
}

View file

@ -0,0 +1,183 @@
// 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"
"slices"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// textprotoCanonical converts a header name to canonical form
func textprotoCanonical(s string) string {
// Simple implementation - capitalize each word
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
}
return strings.Join(words, "-")
}
// pluralize returns "y" or "ies" based on count
func pluralize(count int) string {
if count == 1 {
return "y"
}
return "ies"
}
// parseARCResult parses ARC result from Authentication-Results
// Example: arc=pass
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
result := &api.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.Details = api.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 {
// Get all ARC-related headers
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
// If no ARC headers present, return nil
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
return nil
}
result := &api.ARCResult{
Result: api.ARCResultResultNone,
}
// Count the ARC chain length (number of sets)
chainLength := len(arcSeal)
result.ChainLength = &chainLength
// Validate the ARC chain
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
result.ChainValid = &chainValid
// Determine overall result
if chainLength == 0 {
result.Result = api.ARCResultResultNone
details := "No ARC chain present"
result.Details = &details
} else if !chainValid {
result.Result = api.ARCResultResultFail
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
result.Details = &details
} else {
result.Result = api.ARCResultResultPass
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
result.Details = &details
}
return result
}
// enhanceARCResult enhances an existing ARC result with chain information
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
if arcResult == nil {
return
}
// Get ARC headers
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
// Set chain length if not already set
if arcResult.ChainLength == nil {
chainLength := len(arcSeal)
arcResult.ChainLength = &chainLength
}
// Validate chain if not already validated
if arcResult.ChainValid == nil {
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
arcResult.ChainValid = &chainValid
}
}
// validateARCChain validates the ARC chain for completeness
// Each instance should have all three headers with matching instance numbers
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
// All three header types should have the same count
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
return false
}
if len(arcSeal) == 0 {
return true // No ARC chain is technically valid
}
// Extract instance numbers from each header type
sealInstances := a.extractARCInstances(arcSeal)
sigInstances := a.extractARCInstances(arcMessageSig)
authInstances := a.extractARCInstances(arcAuthResults)
// Check that all instance numbers match and are sequential starting from 1
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
return false
}
// Verify instances are sequential from 1 to N
for i := 1; i <= len(sealInstances); i++ {
if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) {
return false
}
}
return true
}
// extractARCInstances extracts instance numbers from ARC headers
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
var instances []int
re := regexp.MustCompile(`i=(\d+)`)
for _, header := range headers {
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
var instance int
fmt.Sscanf(matches[1], "%d", &instance)
instances = append(instances, instance)
}
}
return instances
}

View file

@ -0,0 +1,150 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.ARCResultResult
}{
{
name: "ARC pass",
part: "arc=pass",
expectedResult: api.ARCResultResultPass,
},
{
name: "ARC fail",
part: "arc=fail",
expectedResult: api.ARCResultResultFail,
},
{
name: "ARC none",
part: "arc=none",
expectedResult: api.ARCResultResultNone,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseARCResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
})
}
}
func TestValidateARCChain(t *testing.T) {
tests := []struct {
name string
arcAuthResults []string
arcMessageSig []string
arcSeal []string
expectedValid bool
}{
{
name: "Empty chain is valid",
arcAuthResults: []string{},
arcMessageSig: []string{},
arcSeal: []string{},
expectedValid: true,
},
{
name: "Valid chain with single hop",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
},
arcSeal: []string{
"i=1; a=rsa-sha256; s=arc; d=example.com",
},
expectedValid: true,
},
{
name: "Valid chain with two hops",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
"i=2; relay.com; arc=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
"i=2; a=rsa-sha256; d=relay.com",
},
arcSeal: []string{
"i=1; a=rsa-sha256; s=arc; d=example.com",
"i=2; a=rsa-sha256; s=arc; d=relay.com",
},
expectedValid: true,
},
{
name: "Invalid chain - missing one header type",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
},
arcSeal: []string{},
expectedValid: false,
},
{
name: "Invalid chain - non-sequential instances",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
"i=3; relay.com; arc=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
"i=3; a=rsa-sha256; d=relay.com",
},
arcSeal: []string{
"i=1; a=rsa-sha256; s=arc; d=example.com",
"i=3; a=rsa-sha256; s=arc; d=relay.com",
},
expectedValid: false,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal)
if valid != tt.expectedValid {
t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid)
}
})
}
}

View file

@ -0,0 +1,75 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.selector or selector)
selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
return result
}
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
if results.Bimi != nil {
switch results.Bimi.Result {
case api.AuthResultResultPass:
return 100
case api.AuthResultResultDeclined:
return 59
default: // fail
return 0
}
}
return 0
}

View file

@ -0,0 +1,94 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseBIMIResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.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,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI fail",
part: "bimi=fail header.d=example.com header.selector=default",
expectedResult: api.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,
expectedDomain: "example.com",
expectedSelector: "v1",
},
{
name: "BIMI none",
part: "bimi=none header.d=example.com",
expectedResult: api.AuthResultResultNone,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseBIMIResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
if tt.expectedSelector != "" {
if result.Selector == nil || *result.Selector != tt.expectedSelector {
var gotSelector string
if result.Selector != nil {
gotSelector = *result.Selector
}
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
}
}
})
}
}

View file

@ -0,0 +1,86 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.s or s)
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.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 {
hasPass = true
} else {
hasNonPass = true
}
}
if hasPass && hasNonPass {
// Could be better
return 90
} else if hasPass {
return 100
} else {
// Has DKIM signatures but none passed
return 20
}
}
return 0
}

View file

@ -0,0 +1,86 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.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,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "DKIM fail",
part: "dkim=fail header.d=example.com header.s=selector1",
expectedResult: api.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,
expectedDomain: "example.com",
expectedSelector: "default",
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseDKIMResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
if result.Selector == nil || *result.Selector != tt.expectedSelector {
var gotSelector string
if result.Selector != nil {
gotSelector = *result.Selector
}
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
}
})
}
}

View file

@ -0,0 +1,68 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract domain (header.from)
domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
return result
}
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
return 100
case api.AuthResultResultNone:
return 33
default: // fail
return 0
}
}
return 0
}

View file

@ -0,0 +1,69 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseDMARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
}{
{
name: "DMARC pass",
part: "dmarc=pass action=none header.from=example.com",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "DMARC fail",
part: "dmarc=fail action=quarantine header.from=example.com",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseDMARCResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
})
}
}

View file

@ -0,0 +1,73 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract IP address (smtp.remote-ip or remote-ip)
ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`)
if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 {
ip := matches[1]
result.Ip = &ip
}
// Extract hostname from parentheses
hostnameRe := regexp.MustCompile(`\(([^)]+)\)`)
if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 {
hostname := matches[1]
result.Hostname = &hostname
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
return result
}
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
if results.Iprev != nil {
switch results.Iprev.Result {
case api.Pass:
return 100
default: // fail, temperror, permerror
return 0
}
}
return 0
}

View file

@ -0,0 +1,225 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseIPRevResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.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"),
},
{
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"),
},
{
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"),
},
{
name: "IPRev temperror",
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
expectedResult: api.Temperror,
expectedIP: api.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"),
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"),
},
{
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"),
},
{
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"),
expectedHostname: nil,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseIPRevResult(tt.part)
// Check result
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
// Check IP
if tt.expectedIP != nil {
if result.Ip == nil {
t.Errorf("IP = nil, want %v", *tt.expectedIP)
} else if *result.Ip != *tt.expectedIP {
t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP)
}
} else {
if result.Ip != nil {
t.Errorf("IP = %v, want nil", *result.Ip)
}
}
// Check hostname
if tt.expectedHostname != nil {
if result.Hostname == nil {
t.Errorf("Hostname = nil, want %v", *tt.expectedHostname)
} else if *result.Hostname != *tt.expectedHostname {
t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname)
}
} else {
if result.Hostname != nil {
t.Errorf("Hostname = %v, want nil", *result.Hostname)
}
}
// Check details
if result.Details == nil {
t.Error("Expected Details to be set, got nil")
}
})
}
}
func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
tests := []struct {
name string
header string
expectedIPRevResult *api.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"),
},
{
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"),
},
{
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"),
expectedHostname: nil,
},
{
name: "No IPRev in header",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com",
expectedIPRevResult: nil,
},
{
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"),
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check IPRev
if tt.expectedIPRevResult != nil {
if results.Iprev == nil {
t.Errorf("Expected IPRev result, got nil")
} else {
if results.Iprev.Result != *tt.expectedIPRevResult {
t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult)
}
if tt.expectedIP != nil {
if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP {
var gotIP string
if results.Iprev.Ip != nil {
gotIP = *results.Iprev.Ip
}
t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP)
}
}
if tt.expectedHostname != nil {
if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname {
var gotHostname string
if results.Iprev.Hostname != nil {
gotHostname = *results.Iprev.Hostname
}
t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname)
}
}
}
} else {
if results.Iprev != nil {
t.Errorf("Expected no IPRev result, got %+v", results.Iprev)
}
}
})
}
}

View file

@ -0,0 +1,105 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract domain
domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
email := matches[1]
// Extract domain from email
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
return result
}
// parseLegacySPF attempts to parse SPF from Received-SPF header
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
return nil
}
result := &api.AuthResult{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
result.Result = api.AuthResultResult(resultStr)
}
result.Details = &receivedSPF
// Try to extract domain
domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`)
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
email := matches[1]
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
return result
}
func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
return 100
case api.AuthResultResultNeutral, api.AuthResultResultNone:
return 50
case api.AuthResultResultSoftfail:
return 17
default: // fail, temperror, permerror
return 0
}
}
return 0
}

View file

@ -0,0 +1,212 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseSPFResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
}{
{
name: "SPF pass with domain",
part: "spf=pass smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "SPF fail",
part: "spf=fail smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "SPF neutral",
part: "spf=neutral smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultNeutral,
expectedDomain: "example.com",
},
{
name: "SPF softfail",
part: "spf=softfail smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultSoftfail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseSPFResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
})
}
}
func TestParseLegacySPF(t *testing.T) {
tests := []struct {
name string
receivedSPF string
expectedResult api.AuthResultResult
expectedDomain *string
expectNil bool
}{
{
name: "SPF pass with envelope-from",
receivedSPF: `pass
(mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched))
receiver=mx.receiver.com;
identity=mailfrom;
envelope-from="user@example.com";
helo=smtp.example.com;
client-ip=192.0.2.10`,
expectedResult: api.AuthResultResultPass,
expectedDomain: api.PtrTo("example.com"),
},
{
name: "SPF fail with sender",
receivedSPF: `fail
(mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender)
receiver=mx.receiver.com;
identity=mailfrom;
sender="sender@test.com";
helo=smtp.test.com;
client-ip=192.0.2.20`,
expectedResult: api.AuthResultResultFail,
expectedDomain: api.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"),
},
{
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"),
},
{
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"),
},
{
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"),
},
{
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"),
},
{
name: "SPF pass without domain extraction",
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
expectedResult: api.AuthResultResultPass,
expectedDomain: nil,
},
{
name: "Empty Received-SPF header",
receivedSPF: "",
expectNil: true,
},
{
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"),
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock email message with Received-SPF header
email := &EmailMessage{
Header: make(map[string][]string),
}
if tt.receivedSPF != "" {
email.Header["Received-Spf"] = []string{tt.receivedSPF}
}
result := analyzer.parseLegacySPF(email)
if tt.expectNil {
if result != nil {
t.Errorf("Expected nil result, got %+v", result)
}
return
}
if result == nil {
t.Fatal("Expected non-nil result, got nil")
}
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if tt.expectedDomain != nil {
if result.Domain == nil {
t.Errorf("Domain = nil, want %v", *tt.expectedDomain)
} else if *result.Domain != *tt.expectedDomain {
t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain)
}
} else {
if result.Domain != nil {
t.Errorf("Domain = %v, want nil", *result.Domain)
}
}
if result.Details == nil {
t.Error("Expected Details to be set, got nil")
} else if *result.Details != tt.receivedSPF {
t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF)
}
})
}
}

View file

@ -0,0 +1,438 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
results *api.AuthenticationResults
expectedScore int
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
},
{
name: "SPF and DKIM only",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
},
expectedScore: 48, // SPF=25 + DKIM=23
},
{
name: "SPF fail, DKIM pass",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultFail,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
},
expectedScore: 23, // SPF=0 + DKIM=23
},
{
name: "SPF softfail",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultSoftfail,
},
},
expectedScore: 4,
},
{
name: "No authentication",
results: &api.AuthenticationResults{},
expectedScore: 0,
},
{
name: "BIMI adds to score",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedScore: 35, // SPF (25) + BIMI (10)
},
}
scorer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := scorer.CalculateAuthenticationScore(tt.results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
}
})
}
}
func TestParseAuthenticationResultsHeader(t *testing.T) {
tests := []struct {
name string
header string
expectedSPFResult *api.AuthResultResult
expectedSPFDomain *string
expectedDKIMCount int
expectedDKIMResult *api.AuthResultResult
expectedDMARCResult *api.AuthResultResult
expectedDMARCDomain *string
expectedBIMIResult *api.AuthResultResult
expectedARCResult *api.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"),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCDomain: api.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"),
expectedDKIMCount: 0,
expectedDMARCResult: nil,
},
{
name: "DKIM only",
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.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),
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"),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.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"),
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),
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
expectedDMARCDomain: api.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"),
expectedDKIMCount: 0,
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
},
{
name: "ARC pass",
header: "mail.example.com; arc=pass",
expectedSPFResult: nil,
expectedDKIMCount: 0,
expectedARCResult: api.PtrTo(api.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"),
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),
},
{
name: "Empty header (authserv-id only)",
header: "mx.google.com",
expectedSPFResult: nil,
expectedDKIMCount: 0,
},
{
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"),
expectedDKIMCount: 0,
},
{
name: "DKIM with short form parameters",
header: "mail.example.com; dkim=pass d=example.com s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
expectedDKIMResult: api.PtrTo(api.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"),
expectedDKIMCount: 0,
},
{
name: "SPF none",
header: "mail.example.com; spf=none",
expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
expectedDKIMCount: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check SPF
if tt.expectedSPFResult != nil {
if results.Spf == nil {
t.Errorf("Expected SPF result, got nil")
} else {
if results.Spf.Result != *tt.expectedSPFResult {
t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult)
}
if tt.expectedSPFDomain != nil {
if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain {
var gotDomain string
if results.Spf.Domain != nil {
gotDomain = *results.Spf.Domain
}
t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain)
}
}
}
} else {
if results.Spf != nil {
t.Errorf("Expected no SPF result, got %+v", results.Spf)
}
}
// Check DKIM count and result
if results.Dkim == nil {
if tt.expectedDKIMCount != 0 {
t.Errorf("Expected %d DKIM results, got nil", tt.expectedDKIMCount)
}
} else {
if len(*results.Dkim) != tt.expectedDKIMCount {
t.Errorf("DKIM count = %d, want %d", len(*results.Dkim), tt.expectedDKIMCount)
}
if tt.expectedDKIMResult != nil && len(*results.Dkim) > 0 {
if (*results.Dkim)[0].Result != *tt.expectedDKIMResult {
t.Errorf("DKIM Result = %v, want %v", (*results.Dkim)[0].Result, *tt.expectedDKIMResult)
}
}
}
// Check DMARC
if tt.expectedDMARCResult != nil {
if results.Dmarc == nil {
t.Errorf("Expected DMARC result, got nil")
} else {
if results.Dmarc.Result != *tt.expectedDMARCResult {
t.Errorf("DMARC Result = %v, want %v", results.Dmarc.Result, *tt.expectedDMARCResult)
}
if tt.expectedDMARCDomain != nil {
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != *tt.expectedDMARCDomain {
var gotDomain string
if results.Dmarc.Domain != nil {
gotDomain = *results.Dmarc.Domain
}
t.Errorf("DMARC Domain = %v, want %v", gotDomain, *tt.expectedDMARCDomain)
}
}
}
} else {
if results.Dmarc != nil {
t.Errorf("Expected no DMARC result, got %+v", results.Dmarc)
}
}
// Check BIMI
if tt.expectedBIMIResult != nil {
if results.Bimi == nil {
t.Errorf("Expected BIMI result, got nil")
} else {
if results.Bimi.Result != *tt.expectedBIMIResult {
t.Errorf("BIMI Result = %v, want %v", results.Bimi.Result, *tt.expectedBIMIResult)
}
}
} else {
if results.Bimi != nil {
t.Errorf("Expected no BIMI result, got %+v", results.Bimi)
}
}
// Check ARC
if tt.expectedARCResult != nil {
if results.Arc == nil {
t.Errorf("Expected ARC result, got nil")
} else {
if results.Arc.Result != *tt.expectedARCResult {
t.Errorf("ARC Result = %v, want %v", results.Arc.Result, *tt.expectedARCResult)
}
}
} else {
if results.Arc != nil {
t.Errorf("Expected no ARC result, got %+v", results.Arc)
}
}
})
}
}
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
// This test verifies that only the first occurrence of each auth method is parsed
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{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Spf == nil {
t.Fatal("Expected SPF result, got nil")
}
if results.Spf.Result != api.AuthResultResultPass {
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
}
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
t.Errorf("Expected domain from first SPF result")
}
})
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{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dmarc == nil {
t.Fatal("Expected DMARC result, got nil")
}
if results.Dmarc.Result != api.AuthResultResultPass {
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
}
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
t.Errorf("Expected domain from first DMARC result")
}
})
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; arc=pass; arc=fail"
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Arc == nil {
t.Fatal("Expected ARC result, got nil")
}
if results.Arc.Result != api.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{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Bimi == nil {
t.Fatal("Expected BIMI result, got nil")
}
if results.Bimi.Result != api.AuthResultResultPass {
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
}
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
t.Errorf("Expected domain from first BIMI result")
}
})
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{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dkim == nil {
t.Fatal("Expected DKIM results, got nil")
}
if len(*results.Dkim) != 2 {
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
}
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
}
if (*results.Dkim)[1].Result != api.AuthResultResultFail {
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
}
})
}

View file

@ -0,0 +1,65 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract details (everything after the result)
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
return result
}
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.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
return 0
default:
// neutral, none, etc.: no impact
return 0
}
}
return 0
}

View file

@ -0,0 +1,144 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDetail string
}{
{
name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)",
expectedResult: api.AuthResultResultPass,
expectedDetail: "pass (Address match)",
},
{
name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)",
expectedResult: api.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)",
},
{
name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass",
expectedResult: api.AuthResultResultPass,
expectedDetail: "pass",
},
{
name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)",
expectedResult: api.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)",
},
{
name: "x-aligned-from none",
part: "x-aligned-from=none",
expectedResult: api.AuthResultResultNone,
expectedDetail: "none",
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseXAlignedFromResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Details == nil {
t.Errorf("Details = nil, want %v", tt.expectedDetail)
} else if *result.Details != tt.expectedDetail {
t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail)
}
})
}
}
func TestCalculateXAlignedFromScore(t *testing.T) {
tests := []struct {
name string
result *api.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,
},
expectedScore: 0,
},
{
name: "neutral result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultNeutral,
},
expectedScore: 0,
},
{
name: "none result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultNone,
},
expectedScore: 0,
},
{
name: "nil result gives zero score",
result: nil,
expectedScore: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.AuthenticationResults{
XAlignedFrom: tt.result,
}
score := analyzer.calculateXAlignedFromScore(results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
}
})
}
}

View file

@ -0,0 +1,73 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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{}
// 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)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.s or s) - though not always present in x-google-dkim
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
if results.XGoogleDkim != nil {
switch results.XGoogleDkim.Result {
case api.AuthResultResultPass:
// pass: don't alter the score
default: // fail
return -100
}
}
return 0
}

View file

@ -0,0 +1,83 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseXGoogleDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.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,
expectedDomain: "1e100.net",
},
{
name: "x-google-dkim pass with short form",
part: "x-google-dkim=pass d=gmail.com",
expectedResult: api.AuthResultResultPass,
expectedDomain: "gmail.com",
},
{
name: "x-google-dkim fail",
part: "x-google-dkim=fail header.d=example.com",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "x-google-dkim with minimal info",
part: "x-google-dkim=pass",
expectedResult: api.AuthResultResultPass,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseXGoogleDKIMResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if tt.expectedDomain != "" {
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
}
})
}
}

986
pkg/analyzer/content.go Normal file
View file

@ -0,0 +1,986 @@
// 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 (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"time"
"unicode"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
// ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct {
Timeout time.Duration
httpClient *http.Client
listUnsubscribeURLs []string // URLs from List-Unsubscribe header
hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
}
// NewContentAnalyzer creates a new content analyzer with configurable timeout
func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
return &ContentAnalyzer{
Timeout: timeout,
httpClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Allow up to 10 redirects
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// ContentResults represents content analysis results
type ContentResults struct {
IsMultipart bool
HTMLValid bool
HTMLErrors []string
Links []LinkCheck
Images []ImageCheck
HasUnsubscribe bool
UnsubscribeLinks []string
TextContent string
HTMLContent string
TextPlainRatio float32 // Ratio of plain text to HTML consistency
ImageTextRatio float32 // Ratio of images to text
SuspiciousURLs []string
ContentIssues []string
HarmfullIssues []string
}
// HasPlaintext returns true if the email has plain text content
func (r *ContentResults) HasPlaintext() bool {
return r.TextContent != ""
}
// LinkCheck represents a link validation result
type LinkCheck struct {
URL string
Valid bool
Status int
Error string
IsSafe bool
Warning string
}
// ImageCheck represents an image validation result
type ImageCheck struct {
Src string
HasAlt bool
AltText string
Valid bool
Error string
IsBroken bool
}
// AnalyzeContent performs content analysis on email message
func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
results := &ContentResults{}
results.IsMultipart = len(email.Parts) > 1
// Parse List-Unsubscribe header URLs for use in link detection
c.listUnsubscribeURLs = email.GetListUnsubscribeURLs()
// Check for one-click unsubscribe support
listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post")
c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click")
// Get HTML and text parts
htmlParts := email.GetHTMLParts()
textParts := email.GetTextParts()
// Analyze HTML parts
if len(htmlParts) > 0 {
for _, part := range htmlParts {
c.analyzeHTML(part.Content, results)
}
}
// Analyze text parts
if len(textParts) > 0 {
for _, part := range textParts {
results.TextContent += part.Content
}
// Extract and validate links from plain text
c.analyzeTextLinks(results.TextContent, results)
}
// Check plain text/HTML consistency
if len(htmlParts) > 0 && len(textParts) > 0 {
results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent)
} else if !results.IsMultipart {
results.TextPlainRatio = 1.0
}
return results
}
// analyzeTextLinks extracts and validates URLs from plain text
func (c *ContentAnalyzer) analyzeTextLinks(textContent string, results *ContentResults) {
// Regular expression to match URLs in plain text
// Matches http://, https://, and www. URLs
urlRegex := regexp.MustCompile(`(?i)\b(?:https?://|www\.)[^\s<>"{}|\\^\[\]` + "`" + `]+`)
matches := urlRegex.FindAllString(textContent, -1)
for _, match := range matches {
// Normalize URL (add http:// if missing)
urlStr := match
if strings.HasPrefix(strings.ToLower(urlStr), "www.") {
urlStr = "http://" + urlStr
}
// Check if this URL already exists in results.Links (from HTML analysis)
exists := false
for _, link := range results.Links {
if link.URL == urlStr {
exists = true
break
}
}
// Only validate if not already checked
if !exists {
linkCheck := c.validateLink(urlStr)
results.Links = append(results.Links, linkCheck)
// Check for suspicious URLs
if !linkCheck.IsSafe {
results.SuspiciousURLs = append(results.SuspiciousURLs, urlStr)
}
}
}
}
// analyzeHTML parses and analyzes HTML content
func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) {
results.HTMLContent = htmlContent
// Parse HTML
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
results.HTMLValid = false
results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err))
return
}
results.HTMLValid = true
// Traverse HTML tree
c.traverseHTML(doc, results)
// Calculate image-to-text ratio
if results.HTMLContent != "" {
textLength := len(c.extractTextFromHTML(htmlContent))
imageCount := len(results.Images)
if textLength > 0 {
results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars
}
}
}
// traverseHTML recursively traverses HTML nodes
func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
// Extract and validate links
href := c.getAttr(n, "href")
if href != "" {
// Check for unsubscribe links
if c.isUnsubscribeLink(href, n) {
results.HasUnsubscribe = true
results.UnsubscribeLinks = append(results.UnsubscribeLinks, href)
}
// Validate link
linkCheck := c.validateLink(href)
// Check for domain misalignment (phishing detection)
linkText := c.getNodeText(n)
if c.hasDomainMisalignment(href, linkText) {
linkCheck.IsSafe = false
if linkCheck.Warning == "" {
linkCheck.Warning = "Link text domain does not match actual URL domain (possible phishing)"
} else {
linkCheck.Warning += "; Link text domain does not match actual URL domain (possible phishing)"
}
}
results.Links = append(results.Links, linkCheck)
// Check for suspicious URLs
if !linkCheck.IsSafe {
results.SuspiciousURLs = append(results.SuspiciousURLs, href)
}
}
case "img":
// Extract and validate images
src := c.getAttr(n, "src")
alt := c.getAttr(n, "alt")
imageCheck := ImageCheck{
Src: src,
HasAlt: alt != "",
AltText: alt,
Valid: src != "",
}
if src == "" {
imageCheck.Error = "Image missing src attribute"
}
results.Images = append(results.Images, imageCheck)
case "script":
// JavaScript in emails is a security risk and typically blocked
results.HarmfullIssues = append(results.HarmfullIssues, "Dangerous <script> tag detected - JavaScript is blocked by most email clients")
case "iframe":
// Iframes are security risks and blocked by most email clients
src := c.getAttr(n, "src")
issue := "Dangerous <iframe> tag detected"
if src != "" {
issue += fmt.Sprintf(" with src='%s'", src)
}
results.HarmfullIssues = append(results.HarmfullIssues, issue+" - iframes are blocked by most email clients")
case "object", "embed", "applet":
// Legacy embedding tags, security risks
results.HarmfullIssues = append(results.HarmfullIssues, fmt.Sprintf("Dangerous <%s> tag detected - legacy embedding tags are security risks and blocked by email clients", n.Data))
case "form":
// Forms in emails can be phishing vectors
action := c.getAttr(n, "action")
issue := "Suspicious <form> tag detected"
if action != "" {
issue += fmt.Sprintf(" with action='%s'", action)
}
results.HarmfullIssues = append(results.HarmfullIssues, issue+" - forms can be phishing vectors and are often blocked")
case "base":
// Base tag can be used for phishing by redirecting relative URLs
href := c.getAttr(n, "href")
issue := "Potentially dangerous <base> tag detected"
if href != "" {
issue += fmt.Sprintf(" with href='%s'", href)
}
results.HarmfullIssues = append(results.HarmfullIssues, issue+" - can redirect all relative URLs")
case "meta":
// Check for suspicious meta redirects
httpEquiv := c.getAttr(n, "http-equiv")
if strings.ToLower(httpEquiv) == "refresh" {
content := c.getAttr(n, "content")
results.HarmfullIssues = append(results.HarmfullIssues, fmt.Sprintf("Suspicious <meta http-equiv='refresh'> tag detected with content='%s' - can be used for phishing redirects", content))
}
case "link":
// Check for external stylesheet links (potential privacy/tracking concerns)
rel := c.getAttr(n, "rel")
href := c.getAttr(n, "href")
if strings.Contains(strings.ToLower(rel), "stylesheet") && href != "" {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
results.ContentIssues = append(results.ContentIssues, fmt.Sprintf("External stylesheet link detected: %s - may cause rendering issues or privacy concerns", href))
}
}
}
}
// Traverse children
for child := n.FirstChild; child != nil; child = child.NextSibling {
c.traverseHTML(child, results)
}
}
// getAttr gets an attribute value from an HTML node
func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
for _, attr := range n.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
// isUnsubscribeLink checks if a link is an unsubscribe link
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
// First check: does the href match a URL from the List-Unsubscribe header?
if slices.Contains(c.listUnsubscribeURLs, href) {
return true
}
// Check href for unsubscribe keywords
lowerHref := strings.ToLower(href)
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"}
for _, keyword := range unsubKeywords {
if strings.Contains(lowerHref, keyword) {
return true
}
}
// Check link text for unsubscribe keywords
text := c.getNodeText(node)
lowerText := strings.ToLower(text)
for _, keyword := range unsubKeywords {
if strings.Contains(lowerText, keyword) {
return true
}
}
return false
}
// getNodeText extracts text content from a node
func (c *ContentAnalyzer) getNodeText(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
var text string
for child := n.FirstChild; child != nil; child = child.NextSibling {
text += c.getNodeText(child)
}
return text
}
// validateLink validates a URL and checks if it's accessible
func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck {
check := LinkCheck{
URL: urlStr,
IsSafe: true,
}
// Parse URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
check.Valid = false
check.Error = fmt.Sprintf("Invalid URL: %v", err)
return check
}
// Check URL safety
if c.isSuspiciousURL(urlStr, parsedURL) {
check.IsSafe = false
check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)"
}
// Only check HTTP/HTTPS links
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
check.Valid = true
return check
}
// Check if link is accessible (with timeout)
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil)
if err != nil {
check.Valid = false
check.Error = fmt.Sprintf("Failed to create request: %v", err)
return check
}
// Set a reasonable user agent
req.Header.Set("User-Agent", "happyDeliver/1.0 (Email Deliverability Tester)")
resp, err := c.httpClient.Do(req)
if err != nil {
// Don't fail on timeout/connection errors for external links
// Just mark as warning
check.Valid = true
check.Status = 0
check.Warning = fmt.Sprintf("Could not verify link: %v", err)
return check
}
defer resp.Body.Close()
check.Status = resp.StatusCode
check.Valid = true
// Check for error status codes
if resp.StatusCode >= 400 {
check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode)
}
return check
}
// hasDomainMisalignment checks if the link text contains a different domain than the actual URL
// This is a common phishing technique (e.g., text shows "paypal.com" but links to "evil.com")
func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
// Parse the actual URL
parsedURL, err := url.Parse(href)
if err != nil {
return false
}
// Extract the actual destination domain/email based on scheme
var actualDomain string
switch parsedURL.Scheme {
case "mailto":
// Extract email address from mailto: URL
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
mailtoAddr := parsedURL.Opaque
// Remove query parameters if present
if idx := strings.Index(mailtoAddr, "?"); idx != -1 {
mailtoAddr = mailtoAddr[:idx]
}
mailtoAddr = strings.TrimSpace(strings.ToLower(mailtoAddr))
// Extract domain from email address
if idx := strings.Index(mailtoAddr, "@"); idx != -1 {
actualDomain = mailtoAddr[idx+1:]
} else {
return false // Invalid mailto
}
case "http":
case "https":
// Check if URL has a host
if parsedURL.Host == "" {
return false
}
// Extract the actual URL's domain (remove port if present)
actualDomain = parsedURL.Host
if idx := strings.LastIndex(actualDomain, ":"); idx != -1 {
actualDomain = actualDomain[:idx]
}
actualDomain = strings.ToLower(actualDomain)
default:
// Skip checks for other URL schemes (tel, etc.)
return false
}
// Normalize link text
linkText = strings.TrimSpace(linkText)
linkText = strings.ToLower(linkText)
// Skip if link text is empty, too short, or just generic text like "click here"
if linkText == "" || len(linkText) < 4 {
return false
}
// Common generic link texts that shouldn't trigger warnings
genericTexts := []string{
"click here", "read more", "learn more", "download", "subscribe",
"unsubscribe", "view online", "view in browser", "click", "here",
"update", "verify", "confirm", "continue", "get started",
// mailto-specific generic texts
"email us", "contact us", "send email", "get in touch", "reach out",
"contact", "email", "write to us",
}
if slices.Contains(genericTexts, linkText) {
return false
}
// Extract domain-like patterns from link text using regex
// Matches patterns like "example.com", "www.example.com", "http://example.com"
domainRegex := regexp.MustCompile(`(?i)(?:https?://)?(?:www\.)?([a-z0-9][-a-z0-9]*\.)+[a-z]{2,}`)
matches := domainRegex.FindAllString(linkText, -1)
if len(matches) == 0 {
return false
}
// Check each domain-like pattern found in the text
for _, textDomain := range matches {
// Normalize the text domain
textDomain = strings.ToLower(textDomain)
textDomain = strings.TrimPrefix(textDomain, "http://")
textDomain = strings.TrimPrefix(textDomain, "https://")
textDomain = strings.TrimPrefix(textDomain, "www.")
// Remove trailing slashes and paths
if idx := strings.Index(textDomain, "/"); idx != -1 {
textDomain = textDomain[:idx]
}
// Compare domains - they should match or the actual URL should be a subdomain of the text domain
if textDomain != actualDomain {
// Check if actual domain is a subdomain of text domain
if !strings.HasSuffix(actualDomain, "."+textDomain) && !strings.HasSuffix(actualDomain, textDomain) {
// Check if they share the same base domain (last 2 parts)
textParts := strings.Split(textDomain, ".")
actualParts := strings.Split(actualDomain, ".")
if len(textParts) >= 2 && len(actualParts) >= 2 {
textBase := strings.Join(textParts[len(textParts)-2:], ".")
actualBase := strings.Join(actualParts[len(actualParts)-2:], ".")
if textBase != actualBase {
return true // Domain mismatch detected!
}
} else {
return true // Domain mismatch detected!
}
}
}
}
return false
}
// isSuspiciousURL checks if a URL looks suspicious
func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool {
// Skip checks for mailto: URLs
if parsedURL.Scheme == "mailto" {
return false
}
// Check for IP address instead of domain
if c.isIPAddress(parsedURL.Host) {
return true
}
// Check for URL shorteners (common ones)
shorteners := []string{
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
"buff.ly", "is.gd", "bl.ink", "short.io",
}
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) {
return true
}
// Check for excessive subdomains (possible obfuscation)
parts := strings.Split(parsedURL.Host, ".")
if len(parts) > 4 {
return true
}
// Check for URL obfuscation techniques
if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing)
return true
}
// Check for suspicious characters in domain
if strings.ContainsAny(parsedURL.Host, "[]()<>") {
return true
}
return false
}
// isIPAddress checks if a string is an IP address
func (c *ContentAnalyzer) isIPAddress(host string) bool {
// Remove port if present
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// Simple check for IPv4
parts := strings.Split(host, ".")
if len(parts) == 4 {
for _, part := range parts {
// Check if all characters are digits
for _, ch := range part {
if !unicode.IsDigit(ch) {
return false
}
}
}
return true
}
// Check for IPv6 (contains colons)
if strings.Contains(host, ":") {
return true
}
return false
}
// extractTextFromHTML extracts plain text from HTML
func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return ""
}
var text strings.Builder
var extract func(*html.Node)
extract = func(n *html.Node) {
if n.Type == html.TextNode {
text.WriteString(" " + n.Data)
}
// Skip script and style tags
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
return
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
extract(child)
}
}
extract(doc)
return strings.TrimSpace(text.String())
}
// calculateTextPlainConsistency compares plain text and HTML versions
func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 {
// Extract text from HTML
htmlPlainText := c.extractTextFromHTML(htmlText)
// Normalize both texts
plainNorm := c.normalizeText(plainText)
htmlNorm := c.normalizeText(htmlPlainText)
// Calculate similarity using simple word overlap
plainWords := strings.Fields(plainNorm)
htmlWords := strings.Fields(htmlNorm)
if len(plainWords) == 0 || len(htmlWords) == 0 {
return 0.0
}
// Count common words by building sets
plainWordSet := make(map[string]int)
for _, word := range plainWords {
plainWordSet[word]++
}
htmlWordSet := make(map[string]int)
for _, word := range htmlWords {
htmlWordSet[word]++
}
// Count matches: for each unique word, count minimum occurrences in both texts
commonWords := 0
for word, plainCount := range plainWordSet {
if htmlCount, exists := htmlWordSet[word]; exists {
// Count the minimum occurrences between both texts
if plainCount < htmlCount {
commonWords += plainCount
} else {
commonWords += htmlCount
}
}
}
// Calculate ratio using total words from both texts (union approach)
// This provides a balanced measure: perfect match = 1.0, partial overlap = 0.3-0.8
totalWords := len(plainWords) + len(htmlWords)
if totalWords == 0 {
return 0.0
}
// Divide by average word count for better scoring
avgWords := float32(totalWords) / 2.0
ratio := float32(commonWords) / avgWords
// Cap at 1.0 for perfect matches
if ratio > 1.0 {
ratio = 1.0
}
return ratio
}
// normalizeText normalizes text for comparison
func (c *ContentAnalyzer) normalizeText(text string) string {
// Convert to lowercase
text = strings.ToLower(text)
// Remove extra whitespace
text = strings.TrimSpace(text)
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return text
}
// GenerateContentAnalysis creates structured content analysis from results
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.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{},
}
// Calculate text-to-image ratio (inverse of image-to-text)
if len(results.Images) > 0 && results.HTMLContent != "" {
textLen := float32(len(c.extractTextFromHTML(results.HTMLContent)))
if textLen > 0 {
ratio := textLen / float32(len(results.Images))
analysis.TextToImageRatio = &ratio
}
}
// Build HTML issues
htmlIssues := []api.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,
Message: errMsg,
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
})
}
}
// Add missing alt text issues
if len(results.Images) > 0 {
missingAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
missingAltCount++
}
}
if missingAltCount > 0 {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.MissingAlt,
Severity: api.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"),
})
}
}
// Add excessive images issue
if results.ImageTextRatio > 10.0 {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.ExcessiveImages,
Severity: api.ContentIssueSeverityMedium,
Message: "Email is excessively image-heavy",
Advice: api.PtrTo("Reduce the number of images relative to text content"),
})
}
// Add suspicious URL issues
for _, suspURL := range results.SuspiciousURLs {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.SuspiciousLink,
Severity: api.ContentIssueSeverityHigh,
Message: "Suspicious URL detected",
Location: &suspURL,
Advice: api.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,
Message: harmfulIssue,
Advice: api.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,
Message: contentIssue,
Advice: api.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
})
}
if len(htmlIssues) > 0 {
analysis.HtmlIssues = &htmlIssues
}
// Convert links
if len(results.Links) > 0 {
links := make([]api.LinkCheck, 0, len(results.Links))
for _, link := range results.Links {
status := api.Valid
if link.Status >= 400 {
status = api.Broken
} else if !link.IsSafe {
status = api.Suspicious
} else if link.Warning != "" {
status = api.Timeout
}
apiLink := api.LinkCheck{
Url: link.URL,
Status: status,
}
if link.Status > 0 {
apiLink.HttpCode = api.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)
}
links = append(links, apiLink)
}
analysis.Links = &links
}
// Convert images
if len(results.Images) > 0 {
images := make([]api.ImageCheck, 0, len(results.Images))
for _, img := range results.Images {
apiImg := api.ImageCheck{
HasAlt: img.HasAlt,
}
if img.Src != "" {
apiImg.Src = &img.Src
}
if img.AltText != "" {
apiImg.AltText = &img.AltText
}
// Simple heuristic: tracking pixels are typically 1x1
apiImg.IsTrackingPixel = api.PtrTo(false)
images = append(images, apiImg)
}
analysis.Images = &images
}
// Unsubscribe methods
if results.HasUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
}
for _, url := range c.listUnsubscribeURLs {
if strings.HasPrefix(url, "mailto:") {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
}
}
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
}
return analysis
}
// CalculateContentScore calculates the content score (0-20 points)
func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) (int, string) {
if results == nil {
return 0, ""
}
var score int = 10
// HTML validity or text alone (10 points)
if results.HTMLValid || (!results.IsMultipart && results.HasPlaintext()) {
score += 10
}
// Requires plain text alternative (10 points)
if results.HasPlaintext() {
score += 10
}
// Links (25 points)
if len(results.Links) > 0 {
brokenLinks := 0
for _, link := range results.Links {
if link.Status >= 400 {
brokenLinks++
}
}
score += 20 * (len(results.Links) - brokenLinks) / len(results.Links)
// Too much links, 10 points penalty
if len(results.Links) > 30 {
score -= 10
}
} else {
// No links is better, less suspiscous
score += 25
}
// Images (15 points)
if len(results.Images) > 0 {
noAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
noAltCount++
}
}
score += 15 * (len(results.Images) - noAltCount) / len(results.Images)
} else {
// No images is Ok
score += 15
}
// Text consistency (15 points)
if results.TextPlainRatio >= 0.3 {
score += 15
}
// Image ratio (15 points)
if results.ImageTextRatio <= 5.0 {
score += 15
} else if results.ImageTextRatio <= 10.0 {
score += 7
}
// Penalize suspicious URLs (deduct up to 5 points)
if len(results.SuspiciousURLs) > 0 {
score -= min(len(results.SuspiciousURLs), 5)
}
// Penalize harmful HTML tags (deduct 20 points per harmful tag, max 40 points)
if len(results.HarmfullIssues) > 0 {
score -= min(len(results.HarmfullIssues)*20, 40)
}
// Ensure score is between 0 and 100
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score, ScoreToGrade(score)
}

View file

@ -28,7 +28,6 @@ import (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
@ -77,17 +76,17 @@ func TestExtractTextFromHTML(t *testing.T) {
{
name: "Multiple elements",
html: "<div><h1>Title</h1><p>Paragraph</p></div>",
expectedText: "TitleParagraph",
expectedText: "Title Paragraph",
},
{
name: "With script tag",
html: "<p>Text</p><script>alert('hi')</script><p>More</p>",
expectedText: "TextMore",
expectedText: "Text More",
},
{
name: "With style tag",
html: "<p>Text</p><style>.class { color: red; }</style><p>More</p>",
expectedText: "TextMore",
expectedText: "Text More",
},
{
name: "Empty HTML",
@ -145,6 +144,74 @@ func TestIsUnsubscribeLink(t *testing.T) {
linkText: "Read more",
expected: false,
},
// Multilingual keyword detection - URL path
{
name: "German abmelden in URL",
href: "https://example.com/abmelden?id=42",
linkText: "Click here",
expected: true,
},
{
name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
href: "https://example.com/se-desabonner?id=42",
linkText: "Click here",
expected: false,
},
// Multilingual keyword detection - link text
{
name: "German Abmelden in link text",
href: "https://example.com/manage?id=42&lang=de",
linkText: "Abmelden",
expected: true,
},
{
name: "French Se désabonner in link text",
href: "https://example.com/manage?id=42&lang=fr",
linkText: "Se désabonner",
expected: true,
},
{
name: "Russian Отписаться in link text",
href: "https://example.com/manage?id=42&lang=ru",
linkText: "Отписаться",
expected: true,
},
{
name: "Chinese 退订 in link text",
href: "https://example.com/manage?id=42&lang=zh",
linkText: "退订",
expected: true,
},
{
name: "Japanese 登録を取り消す in link text",
href: "https://example.com/manage?id=42&lang=ja",
linkText: "登録を取り消す",
expected: true,
},
{
name: "Korean 구독 해지 in link text",
href: "https://example.com/manage?id=42&lang=ko",
linkText: "구독 해지",
expected: true,
},
{
name: "Dutch Uitschrijven in link text",
href: "https://example.com/manage?id=42&lang=nl",
linkText: "Uitschrijven",
expected: true,
},
{
name: "Polish Odsubskrybuj in link text",
href: "https://example.com/manage?id=42&lang=pl",
linkText: "Odsubskrybuj",
expected: true,
},
{
name: "Turkish Üyeliği sonlandır in link text",
href: "https://example.com/manage?id=42&lang=tr",
linkText: "Üyeliği sonlandır",
expected: true,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
@ -214,6 +281,16 @@ func TestIsSuspiciousURL(t *testing.T) {
url: "https://mail.example.com/page",
expected: false,
},
{
name: "Mailto with @ symbol",
url: "mailto:support@example.com",
expected: false,
},
{
name: "Mailto with multiple @ symbols",
url: "mailto:user@subdomain@example.com",
expected: false,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
@ -608,453 +685,6 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) {
}
}
func TestGenerateHTMLValidityCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid HTML",
results: &ContentResults{
HTMLValid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.2,
},
{
name: "Invalid HTML",
results: &ContentResults{
HTMLValid: false,
HTMLErrors: []string{"Parse error"},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateHTMLValidityCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateLinkChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "All links valid",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Status: 200},
{URL: "https://example.org", Valid: true, Status: 200},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.4,
},
{
name: "Broken links",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "Links with warnings",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Warning: "Could not verify"},
},
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.3,
},
{
name: "No links",
results: &ContentResults{},
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.generateLinkChecks(tt.results)
if tt.name == "No links" {
if len(checks) != 0 {
t.Errorf("Expected no checks, got %d", len(checks))
}
return
}
if len(checks) == 0 {
t.Fatal("Expected at least one check")
}
check := checks[0]
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
})
}
}
func TestGenerateImageChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "All images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
{Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"},
},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: false},
{Src: "img2.jpg", HasAlt: false},
},
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Some images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
{Src: "img2.jpg", HasAlt: false},
},
},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.generateImageChecks(tt.results)
if len(checks) == 0 {
t.Fatal("Expected at least one check")
}
check := checks[0]
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateUnsubscribeCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "Has unsubscribe link",
results: &ContentResults{
HasUnsubscribe: true,
UnsubscribeLinks: []string{"https://example.com/unsubscribe"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No unsubscribe link",
results: &ContentResults{},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateUnsubscribeCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateTextConsistencyCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "High consistency",
results: &ContentResults{
TextPlainRatio: 0.8,
},
expectedStatus: api.CheckStatusPass,
},
{
name: "Low consistency",
results: &ContentResults{
TextPlainRatio: 0.1,
},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTextConsistencyCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateImageRatioCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "Reasonable ratio",
results: &ContentResults{
ImageTextRatio: 3.0,
Images: []ImageCheck{{}, {}, {}},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "High ratio",
results: &ContentResults{
ImageTextRatio: 7.0,
Images: make([]ImageCheck, 7),
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Excessive ratio",
results: &ContentResults{
ImageTextRatio: 15.0,
Images: make([]ImageCheck, 15),
},
expectedStatus: api.CheckStatusFail,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateImageRatioCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateSuspiciousURLCheck(t *testing.T) {
results := &ContentResults{
SuspiciousURLs: []string{
"https://bit.ly/abc123",
"https://192.168.1.1/page",
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
check := analyzer.generateSuspiciousURLCheck(results)
if check.Status != api.CheckStatusWarn {
t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
if !strings.Contains(check.Message, "2") {
t.Error("Message should mention the count of suspicious URLs")
}
}
func TestGetContentScore(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minScore float32
maxScore float32
}{
{
name: "Nil results",
results: nil,
minScore: 0.0,
maxScore: 0.0,
},
{
name: "Perfect content",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
minScore: 1.8,
maxScore: 2.0,
},
{
name: "Poor content",
results: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
Images: []ImageCheck{{HasAlt: false}},
HasUnsubscribe: false,
TextPlainRatio: 0.1,
ImageTextRatio: 15.0,
SuspiciousURLs: []string{"url1", "url2"},
},
minScore: 0.0,
maxScore: 0.5,
},
{
name: "Average content",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: false,
TextPlainRatio: 0.5,
ImageTextRatio: 4.0,
},
minScore: 1.0,
maxScore: 1.8,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.GetContentScore(tt.results)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
// Ensure score is capped at 2.0
if score > 2.0 {
t.Errorf("Score %v exceeds maximum of 2.0", score)
}
// Ensure score is not negative
if score < 0.0 {
t.Errorf("Score %v is negative", score)
}
})
}
}
func TestGenerateContentChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextContent: "Plain text",
HTMLContent: "<p>HTML text</p>",
ImageTextRatio: 3.0,
},
minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio
},
{
name: "With suspicious URLs",
results: &ContentResults{
HTMLValid: true,
SuspiciousURLs: []string{"url1"},
},
minChecks: 3, // HTML, Unsubscribe, Suspicious URLs
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateContentChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Content category
for _, check := range checks {
if check.Category != api.Content {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content)
}
}
})
}
}
// Helper functions for testing
func parseHTML(htmlStr string) (*html.Node, error) {
@ -1076,3 +706,276 @@ func findFirstLink(n *html.Node) *html.Node {
func parseURL(urlStr string) (*url.URL, error) {
return url.Parse(urlStr)
}
func TestHasDomainMisalignment(t *testing.T) {
tests := []struct {
name string
href string
linkText string
expected bool
reason string
}{
// Phishing cases - should return true
{
name: "Obvious phishing - different domains",
href: "https://evil.com/page",
linkText: "Click here to verify your paypal.com account",
expected: true,
reason: "Link text shows 'paypal.com' but URL points to 'evil.com'",
},
{
name: "Domain in link text differs from URL",
href: "http://attacker.net",
linkText: "Visit google.com for more info",
expected: true,
reason: "Link text shows 'google.com' but URL points to 'attacker.net'",
},
{
name: "URL shown in text differs from actual URL",
href: "https://phishing-site.xyz/login",
linkText: "https://www.bank.example.com/secure",
expected: true,
reason: "Full URL in text doesn't match actual destination",
},
{
name: "Similar but different domain",
href: "https://paypa1.com/login",
linkText: "Login to your paypal.com account",
expected: true,
reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'",
},
{
name: "Subdomain spoofing",
href: "https://paypal.com.evil.com/login",
linkText: "Verify your paypal.com account",
expected: true,
reason: "Domain is 'evil.com', not 'paypal.com'",
},
{
name: "Multiple domains in text, none match",
href: "https://badsite.com",
linkText: "Transfer from bank.com to paypal.com",
expected: true,
reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'",
},
// Legitimate cases - should return false
{
name: "Exact domain match",
href: "https://example.com/page",
linkText: "Visit example.com for more information",
expected: false,
reason: "Domains match exactly",
},
{
name: "Legitimate subdomain",
href: "https://mail.google.com/inbox",
linkText: "Check your google.com email",
expected: false,
reason: "Subdomain of the mentioned domain",
},
{
name: "www prefix variation",
href: "https://www.example.com/page",
linkText: "Visit example.com",
expected: false,
reason: "www prefix is acceptable variation",
},
{
name: "Generic link text - click here",
href: "https://anywhere.com",
linkText: "click here",
expected: false,
reason: "Generic text doesn't contain a domain",
},
{
name: "Generic link text - read more",
href: "https://example.com/article",
linkText: "Read more",
expected: false,
reason: "Generic text doesn't contain a domain",
},
{
name: "Generic link text - learn more",
href: "https://example.com/info",
linkText: "Learn More",
expected: false,
reason: "Generic text doesn't contain a domain (case insensitive)",
},
{
name: "No domain in link text",
href: "https://example.com/page",
linkText: "Click to continue",
expected: false,
reason: "Link text has no domain reference",
},
{
name: "Short link text",
href: "https://example.com",
linkText: "Go",
expected: false,
reason: "Text too short to contain meaningful domain",
},
{
name: "Empty link text",
href: "https://example.com",
linkText: "",
expected: false,
reason: "Empty text cannot contain domain",
},
{
name: "Mailto link - matching domain",
href: "mailto:support@example.com",
linkText: "Email support@example.com",
expected: false,
reason: "Mailto email matches text email",
},
{
name: "Mailto link - domain mismatch (phishing)",
href: "mailto:attacker@evil.com",
linkText: "Contact support@paypal.com for help",
expected: true,
reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'",
},
{
name: "Mailto link - generic text",
href: "mailto:info@example.com",
linkText: "Contact us",
expected: false,
reason: "Generic text without domain reference",
},
{
name: "Mailto link - same domain different user",
href: "mailto:sales@example.com",
linkText: "Email support@example.com",
expected: false,
reason: "Both emails share the same domain",
},
{
name: "Mailto link - text shows only domain",
href: "mailto:info@example.com",
linkText: "Write to example.com",
expected: false,
reason: "Text domain matches mailto domain",
},
{
name: "Mailto link - domain in text doesn't match",
href: "mailto:scam@phishing.net",
linkText: "Reply to customer-service@amazon.com",
expected: true,
reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text",
},
{
name: "Tel link",
href: "tel:+1234567890",
linkText: "Call example.com support",
expected: false,
reason: "Non-HTTP(S) links are excluded",
},
{
name: "Same base domain with different subdomains",
href: "https://www.example.com/page",
linkText: "Visit blog.example.com",
expected: false,
reason: "Both share same base domain 'example.com'",
},
{
name: "URL with path matches domain in text",
href: "https://example.com/section/page",
linkText: "Go to example.com",
expected: false,
reason: "Domain matches, path doesn't matter",
},
{
name: "Generic text - subscribe",
href: "https://newsletter.example.com/signup",
linkText: "Subscribe",
expected: false,
reason: "Generic call-to-action text",
},
{
name: "Generic text - unsubscribe",
href: "https://example.com/unsubscribe?id=123",
linkText: "Unsubscribe",
expected: false,
reason: "Generic unsubscribe text",
},
{
name: "Generic text - download",
href: "https://files.example.com/document.pdf",
linkText: "Download",
expected: false,
reason: "Generic action text",
},
{
name: "Descriptive text without domain",
href: "https://shop.example.com/products",
linkText: "View our latest products",
expected: false,
reason: "No domain mentioned in text",
},
// Edge cases
{
name: "Domain-like text but not valid domain",
href: "https://example.com",
linkText: "Save up to 50.00 dollars",
expected: false,
reason: "50.00 looks like domain but isn't",
},
{
name: "Text with http prefix matching domain",
href: "https://example.com/page",
linkText: "Visit http://example.com",
expected: false,
reason: "Domains match despite different protocols in display",
},
{
name: "Port in URL should not affect matching",
href: "https://example.com:8080/page",
linkText: "Go to example.com",
expected: false,
reason: "Port number doesn't affect domain matching",
},
{
name: "Whitespace in link text",
href: "https://example.com",
linkText: " example.com ",
expected: false,
reason: "Whitespace should be trimmed",
},
{
name: "Multiple spaces in generic text",
href: "https://example.com",
linkText: "click here",
expected: false,
reason: "Generic text with extra spaces",
},
{
name: "Anchor fragment in URL",
href: "https://example.com/page#section",
linkText: "example.com section",
expected: false,
reason: "Fragment doesn't affect domain matching",
},
{
name: "Query parameters in URL",
href: "https://example.com/page?utm_source=email",
linkText: "Visit example.com",
expected: false,
reason: "Query params don't affect domain matching",
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.hasDomainMisalignment(tt.href, tt.linkText)
if result != tt.expected {
t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s",
tt.href, tt.linkText, result, tt.expected, tt.reason)
}
})
}
}

241
pkg/analyzer/dns.go Normal file
View file

@ -0,0 +1,241 @@
// 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 (
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
resolver DNSResolver
}
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver())
}
// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver.
// If resolver is nil, a StandardDNSResolver will be used.
func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
if resolver == nil {
resolver = NewStandardDNSResolver()
}
return &DNSAnalyzer{
Timeout: timeout,
resolver: resolver,
}
}
// AnalyzeDNS performs DNS validation for the email's domain
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults {
// Extract domain from From address
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
return &api.DNSResults{
Errors: &[]string{"Unable to extract domain from email"},
}
}
fromDomain := *headersResults.DomainAlignment.FromDomain
results := &api.DNSResults{
FromDomain: fromDomain,
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
}
// Determine which domain to check SPF for (Return-Path domain)
// SPF validates the envelope sender (Return-Path), not the From header
spfDomain := fromDomain
if results.RpDomain != nil {
spfDomain = *results.RpDomain
}
// Store sender IP for later use in scoring
var senderIP string
if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 {
firstHop := (*headersResults.ReceivedChain)[0]
if firstHop.Ip != nil && *firstHop.Ip != "" {
senderIP = *firstHop.Ip
ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP)
if len(ptrRecords) > 0 {
results.PtrRecords = &ptrRecords
}
if len(forwardRecords) > 0 {
results.PtrForwardRecords = &forwardRecords
}
}
}
// Check MX records for From domain (where replies would go)
results.FromMxRecords = d.checkMXRecords(fromDomain)
// Check MX records for Return-Path domain (where bounces would go)
// Only check if Return-Path domain is different from From domain
if results.RpDomain != nil && *results.RpDomain != fromDomain {
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
}
// 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)
if dkimRecord != nil {
if results.DkimRecords == nil {
results.DkimRecords = new([]api.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
results.DmarcRecord = d.checkDMARCRecord(fromDomain)
// Check BIMI record (for From domain - branding is based on visible sender)
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
return results
}
// 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{
FromDomain: domain,
}
// Check MX records
results.FromMxRecords = d.checkMXRecords(domain)
// Check SPF records
results.SpfRecords = d.checkSPFRecords(domain)
// Check DMARC record
results.DmarcRecord = d.checkDMARCRecord(domain)
// Check BIMI record with default selector
results.BimiRecord = d.checkBIMIRecord(domain, "default")
return results
}
// 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) {
if results == nil {
return 0, ""
}
score := 0
// MX Records: 30 points (only one domain to check)
mxScore := d.calculateMXScore(results)
// Since calculateMXScore checks both From and RP domains,
// and we only have From domain, we use the full score
score += 30 * mxScore / 100
// SPF Records: 30 points
score += 30 * d.calculateSPFScore(results) / 100
// DMARC Record: 40 points
score += 40 * d.calculateDMARCScore(results) / 100
// BIMI Record: only bonus
if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 {
return 100, "A+"
}
}
// Ensure score doesn't exceed maximum
if score > 100 {
score = 100
}
// Ensure score is non-negative
if score < 0 {
score = 0
}
return score, ScoreToGradeKind(score)
}
// 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) {
if results == nil {
return 0, ""
}
score := 0
// PTR and Forward DNS: 20 points
score += 20 * d.calculatePTRScore(results, senderIP) / 100
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
score += 20 * d.calculateMXScore(results) / 100
// SPF Records: 20 points
score += 20 * d.calculateSPFScore(results) / 100
// DKIM Records: 20 points
score += 20 * d.calculateDKIMScore(results) / 100
// DMARC Record: 20 points
score += 20 * d.calculateDMARCScore(results) / 100
// BIMI Record
// BIMI is optional but indicates advanced email branding
if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 {
return 100, "A+"
}
}
// Ensure score doesn't exceed maximum
if score > 100 {
score = 100
}
// Ensure score is non-negative
if score < 0 {
score = 0
}
return score, ScoreToGrade(score)
}

114
pkg/analyzer/dns_bimi.go Normal file
View file

@ -0,0 +1,114 @@
// 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 (
"context"
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
// BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil {
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.PtrTo("No BIMI record found"),
}
}
// Concatenate all TXT record parts (BIMI can be split)
bimiRecord := strings.Join(txtRecords, "")
// Extract logo URL and VMC URL
logoURL := d.extractBIMITag(bimiRecord, "l")
vmcURL := d.extractBIMITag(bimiRecord, "a")
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) {
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: false,
Error: api.PtrTo("BIMI record appears malformed"),
}
}
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: true,
}
}
// extractBIMITag extracts a tag value from a BIMI record
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
// Look for tag=value pattern
re := regexp.MustCompile(tag + `=([^;]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// validateBIMI performs basic BIMI record validation
func (d *DNSAnalyzer) validateBIMI(record string) bool {
// Must start with v=BIMI1
if !strings.HasPrefix(record, "v=BIMI1") {
return false
}
// Must have a logo URL tag (l=)
if !strings.Contains(record, "l=") {
return false
}
return true
}

View file

@ -0,0 +1,128 @@
// 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 (
"testing"
"time"
)
func TestExtractBIMITag(t *testing.T) {
tests := []struct {
name string
record string
tag string
expectedValue string
}{
{
name: "Extract logo URL (l tag)",
record: "v=BIMI1; l=https://example.com/logo.svg",
tag: "l",
expectedValue: "https://example.com/logo.svg",
},
{
name: "Extract VMC URL (a tag)",
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
tag: "a",
expectedValue: "https://example.com/vmc.pem",
},
{
name: "Tag not found",
record: "v=BIMI1; l=https://example.com/logo.svg",
tag: "a",
expectedValue: "",
},
{
name: "Tag with spaces",
record: "v=BIMI1; l= https://example.com/logo.svg ",
tag: "l",
expectedValue: "https://example.com/logo.svg",
},
{
name: "Empty record",
record: "",
tag: "l",
expectedValue: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractBIMITag(tt.record, tt.tag)
if result != tt.expectedValue {
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
}
})
}
}
func TestValidateBIMI(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid BIMI with logo URL",
record: "v=BIMI1; l=https://example.com/logo.svg",
expected: true,
},
{
name: "Valid BIMI with logo and VMC",
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
expected: true,
},
{
name: "Invalid BIMI - no version",
record: "l=https://example.com/logo.svg",
expected: false,
},
{
name: "Invalid BIMI - wrong version",
record: "v=BIMI2; l=https://example.com/logo.svg",
expected: false,
},
{
name: "Invalid BIMI - no logo URL",
record: "v=BIMI1",
expected: false,
},
{
name: "Invalid BIMI - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateBIMI(tt.record)
if result != tt.expected {
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}

116
pkg/analyzer/dns_dkim.go Normal file
View file

@ -0,0 +1,116 @@
// 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 (
"context"
"fmt"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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)
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,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.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),
Valid: false,
Error: api.PtrTo("DKIM record appears malformed"),
}
}
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Record: &dkimRecord,
Valid: true,
}
}
// 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 strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false
}
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
for _, dkim := range *results.DkimRecords {
if dkim.Valid {
hasValidDKIM = true
break
}
}
if hasValidDKIM {
score += 100
} else {
// Partial credit if DKIM record exists but has issues
score += 25
}
}
return
}

View file

@ -0,0 +1,72 @@
// 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 (
"testing"
"time"
)
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DKIM with version",
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Valid DKIM without version",
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Invalid DKIM - no public key",
record: "v=DKIM1; k=rsa",
expected: false,
},
{
name: "Invalid DKIM - wrong version",
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: false,
},
{
name: "Invalid DKIM - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDKIM(tt.record)
if result != tt.expected {
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}

256
pkg/analyzer/dns_dmarc.go Normal file
View file

@ -0,0 +1,256 @@
// 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 (
"context"
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// 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)
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)),
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if dmarcRecord == "" {
return &api.DMARCRecord{
Valid: false,
Error: api.PtrTo("No DMARC record found"),
}
}
// 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,
Valid: false,
Error: api.PtrTo("DMARC record appears malformed"),
}
}
return &api.DMARCRecord{
Record: &dmarcRecord,
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: true,
}
}
// 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]
}
return "unknown"
}
// 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
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
if !strings.Contains(record, "p=") {
return false
}
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 {
score += 50
// Bonus points for stricter policies
if results.DmarcRecord.Policy != nil {
switch *results.DmarcRecord.Policy {
case "reject":
// Strictest policy - full points already awarded
score += 25
case "quarantine":
// Good policy - no deduction
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 {
score += 5
}
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict {
score += 5
}
// Subdomain policy scoring (sp tag)
// +3 for stricter or equal subdomain policy, -3 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
score += 15
} else {
// Subdomain policy is weaker
score -= 15
}
} else {
// No sp tag means subdomains inherit main policy (good default)
score += 15
}
// Percentage scoring (pct tag)
// Apply the percentage on the current score
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

@ -0,0 +1,343 @@
// 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 (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestExtractDMARCPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy string
}{
{
name: "Policy none",
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
expectedPolicy: "none",
},
{
name: "Policy quarantine",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPolicy: "quarantine",
},
{
name: "Policy reject",
record: "v=DMARC1; p=reject; sp=reject",
expectedPolicy: "reject",
},
{
name: "No policy",
record: "v=DMARC1",
expectedPolicy: "unknown",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
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)
}
})
}
}
func TestValidateDMARC(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DMARC",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
expected: true,
},
{
name: "Valid DMARC minimal",
record: "v=DMARC1; p=none",
expected: true,
},
{
name: "Invalid DMARC - no version",
record: "p=quarantine",
expected: false,
},
{
name: "Invalid DMARC - no policy",
record: "v=DMARC1",
expected: false,
},
{
name: "Invalid DMARC - wrong version",
record: "v=DMARC2; p=reject",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDMARC(tt.record)
if result != tt.expected {
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestExtractDMARCSPFAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment 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",
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedAlignment: "strict",
},
{
name: "Both alignments specified - check SPF relaxed",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "relaxed",
},
{
name: "Complex record with SPF strict",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; 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.extractDMARCSPFAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
}
if string(*result) != tt.expectedAlignment {
t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
}
})
}
}
func TestExtractDMARCDKIMAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment 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",
record: "v=DMARC1; p=quarantine; sp=none",
expectedPolicy: api.PtrTo("none"),
},
{
name: "Subdomain policy - quarantine",
record: "v=DMARC1; p=reject; sp=quarantine",
expectedPolicy: api.PtrTo("quarantine"),
},
{
name: "Subdomain policy - reject",
record: "v=DMARC1; p=quarantine; sp=reject",
expectedPolicy: api.PtrTo("reject"),
},
{
name: "No subdomain policy specified (defaults to main policy)",
record: "v=DMARC1; p=quarantine",
expectedPolicy: 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"),
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
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)
}
} else {
if result == nil {
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
}
if string(*result) != *tt.expectedPolicy {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
}
}
})
}
}
func TestExtractDMARCPercentage(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,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPercentage(tt.record)
if tt.expectedPercentage == nil {
if result != nil {
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage)
}
if *result != *tt.expectedPercentage {
t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage)
}
}
})
}
}

94
pkg/analyzer/dns_fcr.go Normal file
View file

@ -0,0 +1,94 @@
// 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 (
"context"
"git.happydns.org/happyDeliver/internal/api"
)
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
// Returns PTR hostnames and their corresponding forward-resolved IPs
func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
// Perform reverse DNS lookup (PTR)
ptrNames, err := d.resolver.LookupAddr(ctx, ip)
if err != nil || len(ptrNames) == 0 {
return nil, nil
}
var forwardIPs []string
seenIPs := make(map[string]bool)
// For each PTR record, perform forward DNS lookup (A/AAAA)
for _, ptrName := range ptrNames {
// Look up A records
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
aRecords, err := d.resolver.LookupHost(ctx, ptrName)
cancel()
if err == nil {
for _, forwardIP := range aRecords {
if !seenIPs[forwardIP] {
forwardIPs = append(forwardIPs, forwardIP)
seenIPs[forwardIP] = true
}
}
}
}
return ptrNames, forwardIPs
}
// 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) {
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
// 50 points for having PTR records
score += 50
if len(*results.PtrRecords) > 1 {
// Penalty has it's bad to have multiple PTR records
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 != "" {
// Verify that the sender IP is in the list of forward-resolved IPs
fcrDnsValid := false
for _, forwardIP := range *results.PtrForwardRecords {
if forwardIP == senderIP {
fcrDnsValid = true
break
}
}
if fcrDnsValid {
score += 50
}
}
}
return
}

115
pkg/analyzer/dns_mx.go Normal file
View file

@ -0,0 +1,115 @@
// 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 (
"context"
"fmt"
"git.happydns.org/happyDeliver/internal/api"
)
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return &[]api.MXRecord{
{
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
},
}
}
if len(mxRecords) == 0 {
return &[]api.MXRecord{
{
Valid: false,
Error: api.PtrTo("No MX records found"),
},
}
}
var results []api.MXRecord
for _, mx := range mxRecords {
results = append(results, api.MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
return &results
}
func (d *DNSAnalyzer) calculateMXScore(results *api.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 {
hasValidFromMX := false
for _, mx := range *results.FromMxRecords {
if mx.Valid {
hasValidFromMX = true
break
}
}
if hasValidFromMX {
score += 50
}
}
// Return-Path domain MX records (10 points) - needed for bounces
if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 {
hasValidRpMX := false
for _, mx := range *results.RpMxRecords {
if mx.Valid {
hasValidRpMX = true
break
}
}
if hasValidRpMX {
score += 50
}
} else if results.RpDomain != nil && *results.RpDomain != results.FromDomain {
// If Return-Path domain is different but has no MX records, it's a problem
// Don't deduct points if RP domain is same as From domain (already checked)
} else {
// If Return-Path is same as From domain, give full 10 points for RP MX
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
hasValidFromMX := false
for _, mx := range *results.FromMxRecords {
if mx.Valid {
hasValidFromMX = true
break
}
}
if hasValidFromMX {
score += 50
}
}
}
return
}

View file

@ -0,0 +1,80 @@
// 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 (
"context"
"net"
)
// 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.
type DNSResolver interface {
// LookupMX returns the DNS MX records for the given domain.
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
// LookupTXT returns the DNS TXT records for the given domain.
LookupTXT(ctx context.Context, name string) ([]string, error)
// LookupAddr performs a reverse lookup for the given IP address,
// returning a list of hostnames mapping to that address.
LookupAddr(ctx context.Context, addr string) ([]string, error)
// LookupHost looks up the given hostname using the local resolver.
// It returns a slice of that host's addresses (IPv4 and IPv6).
LookupHost(ctx context.Context, host string) ([]string, error)
}
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver.
type StandardDNSResolver struct {
resolver *net.Resolver
}
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings.
func NewStandardDNSResolver() DNSResolver {
return &StandardDNSResolver{
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// LookupMX implements DNSResolver.LookupMX using net.Resolver.
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
return r.resolver.LookupMX(ctx, name)
}
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver.
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return r.resolver.LookupTXT(ctx, name)
}
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver.
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return r.resolver.LookupAddr(ctx, addr)
}
// LookupHost implements DNSResolver.LookupHost using net.Resolver.
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
return r.resolver.LookupHost(ctx, host)
}

367
pkg/analyzer/dns_spf.go Normal file
View file

@ -0,0 +1,367 @@
// 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 (
"context"
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.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 {
const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth {
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("Maximum SPF include depth exceeded"),
},
}
}
// Prevent circular references
if visited[domain] {
return &[]api.SPFRecord{}
}
visited[domain] = true
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
},
}
}
// Find SPF record (starts with "v=spf1")
var spfRecord string
spfCount := 0
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfRecord = txt
spfCount++
}
}
if spfCount == 0 {
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("No SPF record found"),
},
}
}
var results []api.SPFRecord
if spfCount > 1 {
results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: false,
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
})
return &results
}
// Basic validation
validationErr := d.validateSPF(spfRecord, isMainRecord)
// Extract the "all" mechanism qualifier
var allQualifier *api.SPFRecordAllQualifier
var errMsg *string
if validationErr != nil {
errMsg = api.PtrTo(validationErr.Error())
} else {
// Extract qualifier from the "all" mechanism
if strings.HasSuffix(spfRecord, " -all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
} else if strings.HasSuffix(spfRecord, " ~all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
} else if strings.HasSuffix(spfRecord, " +all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
} else if strings.HasSuffix(spfRecord, " ?all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
} else if strings.HasSuffix(spfRecord, " all") {
// Implicit + qualifier (default)
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
}
}
results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: validationErr == nil,
AllQualifier: allQualifier,
Error: errMsg,
})
// Check for redirect= modifier first (it replaces the entire SPF policy)
redirectDomain := d.extractSPFRedirect(spfRecord)
if redirectDomain != "" {
// redirect= replaces the current domain's policy entirely
// Only follow if no other mechanisms matched (per RFC 7208)
redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false)
if redirectRecords != nil {
results = append(results, *redirectRecords...)
}
return &results
}
// Extract and resolve include: directives
includes := d.extractSPFIncludes(spfRecord)
for _, includeDomain := range includes {
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false)
if includedRecords != nil {
results = append(results, *includedRecords...)
}
}
return &results
}
// extractSPFIncludes extracts all include: domains from an SPF record
func (d *DNSAnalyzer) extractSPFIncludes(record string) []string {
var includes []string
re := regexp.MustCompile(`include:([^\s]+)`)
matches := re.FindAllStringSubmatch(record, -1)
for _, match := range matches {
if len(match) > 1 {
includes = append(includes, match[1])
}
}
return includes
}
// extractSPFRedirect extracts the redirect= domain from an SPF record
// The redirect= modifier replaces the current domain's SPF policy with that of the target domain
func (d *DNSAnalyzer) extractSPFRedirect(record string) string {
re := regexp.MustCompile(`redirect=([^\s]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// isValidSPFMechanism checks if a token is a valid SPF mechanism or modifier
func (d *DNSAnalyzer) isValidSPFMechanism(token string) error {
// Remove qualifier prefix if present (+, -, ~, ?)
mechanism := strings.TrimLeft(token, "+-~?")
// Check if it's a modifier (contains =)
if strings.Contains(mechanism, "=") {
// Allow known modifiers: redirect=, exp=, and RFC 6652 modifiers (ra=, rp=, rr=)
if strings.HasPrefix(mechanism, "redirect=") ||
strings.HasPrefix(mechanism, "exp=") ||
strings.HasPrefix(mechanism, "ra=") ||
strings.HasPrefix(mechanism, "rp=") ||
strings.HasPrefix(mechanism, "rr=") {
return nil
}
// Check if it's a common mistake (using = instead of :)
parts := strings.SplitN(mechanism, "=", 2)
if len(parts) == 2 {
mechanismName := parts[0]
knownMechanisms := []string{"include", "a", "mx", "ptr", "exists"}
for _, known := range knownMechanisms {
if mechanismName == known {
return fmt.Errorf("invalid syntax '%s': mechanism '%s' should use ':' not '='", token, mechanismName)
}
}
}
return fmt.Errorf("unknown modifier '%s'", token)
}
// Check standalone mechanisms (no domain/value required)
if mechanism == "all" || mechanism == "a" || mechanism == "mx" || mechanism == "ptr" {
return nil
}
// Check mechanisms with domain/value
knownPrefixes := []string{
"include:",
"a:", "a/",
"mx:", "mx/",
"ptr:",
"ip4:",
"ip6:",
"exists:",
}
for _, prefix := range knownPrefixes {
if strings.HasPrefix(mechanism, prefix) {
return nil
}
}
return fmt.Errorf("unknown mechanism '%s'", token)
}
// validateSPF performs basic SPF record validation
// isMainRecord indicates if this is the primary domain's record (not an included one)
func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error {
// Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") {
return fmt.Errorf("SPF record must start with 'v=spf1'")
}
// Parse and validate each token in the SPF record
tokens := strings.Fields(record)
hasRedirect := false
for i, token := range tokens {
// Skip the version tag
if i == 0 && token == "v=spf1" {
continue
}
// Check if it's a valid mechanism
if err := d.isValidSPFMechanism(token); err != nil {
return err
}
// Track if we have a redirect modifier
mechanism := strings.TrimLeft(token, "+-~?")
if strings.HasPrefix(mechanism, "redirect=") {
hasRedirect = true
}
}
// Check for redirect= modifier (which replaces the need for an 'all' mechanism)
if hasRedirect {
return nil
}
// Only check for 'all' mechanism on the main record, not on included records
if isMainRecord {
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
hasValidEnding := false
for _, ending := range validEndings {
if strings.HasSuffix(record, ending) {
hasValidEnding = true
break
}
}
if !hasValidEnding {
return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier")
}
}
return nil
}
// hasSPFStrictFail checks if SPF record has strict -all mechanism
func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
return strings.HasSuffix(record, " -all")
}
func (d *DNSAnalyzer) calculateSPFScore(results *api.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
// Loop through records to find the last redirect or the first non-redirect
mainSPFIndex := 0
for i := 0; i < len(*results.SpfRecords); i++ {
spfRecord := (*results.SpfRecords)[i]
if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") {
// This is a redirect, check if there's a next record
if i+1 < len(*results.SpfRecords) {
mainSPFIndex = i + 1
} else {
// Redirect exists but no target record found
break
}
} else {
// Found a non-redirect record
mainSPFIndex = i
break
}
}
mainSPF := (*results.SpfRecords)[mainSPFIndex]
if mainSPF.Valid {
// Full points for valid SPF
score += 75
// Check if DMARC is configured with strict policy as all mechanism is less significant
dmarcStrict := results.DmarcRecord != nil &&
results.DmarcRecord.Valid && results.DmarcRecord.Policy != nil &&
(*results.DmarcRecord.Policy == "quarantine" ||
*results.DmarcRecord.Policy == "reject")
// Deduct points based on the all mechanism qualifier
if mainSPF.AllQualifier != nil {
switch *mainSPF.AllQualifier {
case "-":
// Strict fail - no deduction, this is the recommended policy
score += 25
case "~":
// Softfail - if DMARC is quarantine or reject, treat it mostly like strict fail
if dmarcStrict {
score += 20
}
// Otherwise, moderate penalty (no points added or deducted)
case "+", "?":
// Pass/neutral - severe penalty
if !dmarcStrict {
score -= 25
}
}
} else {
// No 'all' mechanism qualifier extracted - severe penalty
score -= 25
}
} else if mainSPF.Record != nil {
// Partial credit if SPF record exists but has issues
score += 25
}
}
return
}

View file

@ -0,0 +1,284 @@
// 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"
"time"
)
func TestValidateSPF(t *testing.T) {
tests := []struct {
name string
record string
expectError bool
errorMsg string // Expected error message (substring match)
}{
{
name: "Valid SPF with -all",
record: "v=spf1 include:_spf.example.com -all",
expectError: false,
},
{
name: "Valid SPF with ~all",
record: "v=spf1 ip4:192.0.2.0/24 ~all",
expectError: false,
},
{
name: "Valid SPF with +all",
record: "v=spf1 +all",
expectError: false,
},
{
name: "Valid SPF with ?all",
record: "v=spf1 mx ?all",
expectError: false,
},
{
name: "Valid SPF with redirect",
record: "v=spf1 redirect=_spf.example.com",
expectError: false,
},
{
name: "Valid SPF with redirect and mechanisms",
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com",
expectError: false,
},
{
name: "Valid SPF with multiple mechanisms",
record: "v=spf1 a mx ip4:192.0.2.0/24 include:_spf.example.com -all",
expectError: false,
},
{
name: "Valid SPF with exp modifier",
record: "v=spf1 mx exp=explain.example.com -all",
expectError: false,
},
{
name: "Invalid SPF - no version",
record: "include:_spf.example.com -all",
expectError: true,
errorMsg: "must start with 'v=spf1'",
},
{
name: "Invalid SPF - no all mechanism or redirect",
record: "v=spf1 include:_spf.example.com",
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Invalid SPF - wrong version",
record: "v=spf2 include:_spf.example.com -all",
expectError: true,
errorMsg: "must start with 'v=spf1'",
},
{
name: "Invalid SPF - include= instead of include:",
record: "v=spf1 include=icloud.com ~all",
expectError: true,
errorMsg: "should use ':' not '='",
},
{
name: "Invalid SPF - a= instead of a:",
record: "v=spf1 a=example.com -all",
expectError: true,
errorMsg: "should use ':' not '='",
},
{
name: "Invalid SPF - mx= instead of mx:",
record: "v=spf1 mx=example.com -all",
expectError: true,
errorMsg: "should use ':' not '='",
},
{
name: "Invalid SPF - unknown mechanism",
record: "v=spf1 foobar -all",
expectError: true,
errorMsg: "unknown mechanism",
},
{
name: "Invalid SPF - unknown modifier",
record: "v=spf1 -all unknown=value",
expectError: true,
errorMsg: "unknown modifier",
},
{
name: "Valid SPF with RFC 6652 ra modifier",
record: "v=spf1 mx ra=postmaster -all",
expectError: false,
},
{
name: "Valid SPF with RFC 6652 rp modifier",
record: "v=spf1 mx rp=100 -all",
expectError: false,
},
{
name: "Valid SPF with RFC 6652 rr modifier",
record: "v=spf1 mx rr=all -all",
expectError: false,
},
{
name: "Valid SPF with all RFC 6652 modifiers",
record: "v=spf1 mx ra=postmaster rp=50 rr=fail -all",
expectError: false,
},
{
name: "Valid SPF with RFC 6652 modifiers and redirect",
record: "v=spf1 ip4:192.0.2.0/24 ra=abuse redirect=_spf.example.com",
expectError: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test as main record (isMainRecord = true) since these tests check overall SPF validity
err := analyzer.validateSPF(tt.record, true)
if tt.expectError {
if err == nil {
t.Errorf("validateSPF(%q) expected error but got nil", tt.record)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("validateSPF(%q) error = %q, want error containing %q", tt.record, err.Error(), tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("validateSPF(%q) unexpected error: %v", tt.record, err)
}
}
})
}
}
func TestValidateSPF_IncludedRecords(t *testing.T) {
tests := []struct {
name string
record string
isMainRecord bool
expectError bool
errorMsg string
}{
{
name: "Main record without 'all' - should error",
record: "v=spf1 include:_spf.example.com",
isMainRecord: true,
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Included record without 'all' - should NOT error",
record: "v=spf1 include:_spf.example.com",
isMainRecord: false,
expectError: false,
},
{
name: "Included record with only mechanisms - should NOT error",
record: "v=spf1 ip4:192.0.2.0/24 mx",
isMainRecord: false,
expectError: false,
},
{
name: "Main record with only mechanisms - should error",
record: "v=spf1 ip4:192.0.2.0/24 mx",
isMainRecord: true,
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Included record with 'all' - valid",
record: "v=spf1 ip4:192.0.2.0/24 -all",
isMainRecord: false,
expectError: false,
},
{
name: "Main record with 'all' - valid",
record: "v=spf1 ip4:192.0.2.0/24 -all",
isMainRecord: true,
expectError: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := analyzer.validateSPF(tt.record, tt.isMainRecord)
if tt.expectError {
if err == nil {
t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err)
}
}
})
}
}
func TestExtractSPFRedirect(t *testing.T) {
tests := []struct {
name string
record string
expectedRedirect string
}{
{
name: "SPF with redirect",
record: "v=spf1 redirect=_spf.example.com",
expectedRedirect: "_spf.example.com",
},
{
name: "SPF with redirect and other mechanisms",
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com",
expectedRedirect: "_spf.google.com",
},
{
name: "SPF without redirect",
record: "v=spf1 include:_spf.example.com -all",
expectedRedirect: "",
},
{
name: "SPF with only all mechanism",
record: "v=spf1 -all",
expectedRedirect: "",
},
{
name: "Empty record",
record: "",
expectedRedirect: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractSPFRedirect(tt.record)
if result != tt.expectedRedirect {
t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect)
}
})
}
}

58
pkg/analyzer/dns_test.go Normal file
View file

@ -0,0 +1,58 @@
// 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 (
"testing"
"time"
)
func TestNewDNSAnalyzer(t *testing.T) {
tests := []struct {
name string
timeout time.Duration
expectedTimeout time.Duration
}{
{
name: "Default timeout",
timeout: 0,
expectedTimeout: 10 * time.Second,
},
{
name: "Custom timeout",
timeout: 5 * time.Second,
expectedTimeout: 5 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := NewDNSAnalyzer(tt.timeout)
if analyzer.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout)
}
if analyzer.resolver == nil {
t.Error("Resolver should not be nil")
}
})
}
}

696
pkg/analyzer/headers.go Normal file
View file

@ -0,0 +1,696 @@
// 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"
"net"
"net/mail"
"regexp"
"strings"
"time"
"golang.org/x/net/publicsuffix"
"git.happydns.org/happyDeliver/internal/api"
)
// HeaderAnalyzer analyzes email header quality and structure
type HeaderAnalyzer struct{}
// NewHeaderAnalyzer creates a new header analyzer
func NewHeaderAnalyzer() *HeaderAnalyzer {
return &HeaderAnalyzer{}
}
// CalculateHeaderScore evaluates email structural quality from header analysis
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
if analysis == nil || analysis.Headers == nil {
return 0, ' '
}
score := 0
maxGrade := 6
headers := *analysis.Headers
// RP and From alignment (25 points)
if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
// Bad domain alignment, cap grade to C
maxGrade -= 2
} else if *analysis.DomainAlignment.Aligned {
score += 25
} else if *analysis.DomainAlignment.RelaxedAligned {
score += 20
}
// Check required headers (RFC 5322) - 30 points
requiredHeaders := []string{"from", "date", "message-id"}
requiredCount := len(requiredHeaders)
presentRequired := 0
for _, headerName := range requiredHeaders {
if check, exists := headers[headerName]; exists && check.Present {
presentRequired++
}
}
if presentRequired == requiredCount {
score += 30
} else {
score += int(30 * (float32(presentRequired) / float32(requiredCount)))
maxGrade = 1
}
// Check recommended headers (15 points)
recommendedHeaders := []string{"subject", "to"}
// Add reply-to when from is a no-reply address
if h.isNoReplyAddress(headers["from"]) {
recommendedHeaders = append(recommendedHeaders, "reply-to")
}
recommendedCount := len(recommendedHeaders)
presentRecommended := 0
for _, headerName := range recommendedHeaders {
if check, exists := headers[headerName]; exists && check.Present {
presentRecommended++
}
}
score += presentRecommended * 15 / recommendedCount
if presentRecommended < recommendedCount {
maxGrade -= 1
}
// Check for proper MIME structure (20 points)
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
score += 20
} else {
maxGrade -= 1
}
// Check MIME-Version header (-5 points if present but not "1.0")
if check, exists := headers["mime-version"]; exists && check.Present {
if check.Valid != nil && !*check.Valid {
score -= 5
}
}
// Check Message-ID format (10 points)
if check, exists := headers["message-id"]; exists && check.Present {
// If Valid is set and true, award points
if check.Valid != nil && *check.Valid {
score += 10
} else {
maxGrade -= 1
}
} else {
maxGrade -= 1
}
// Ensure score doesn't exceed 100
if score > 100 {
score = 100
}
grade := 'A' + max(6-maxGrade, 0)
return score, rune(grade)
}
// isValidMessageID checks if a Message-ID has proper format
func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool {
// Basic check: should be in format <...@...>
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
return false
}
// Remove angle brackets
messageID = strings.TrimPrefix(messageID, "<")
messageID = strings.TrimSuffix(messageID, ">")
// Should contain @ symbol
if !strings.Contains(messageID, "@") {
return false
}
parts := strings.Split(messageID, "@")
if len(parts) != 2 {
return false
}
// Both parts should be non-empty
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// parseEmailDate attempts to parse an email date string using common email date formats
// Returns the parsed time and an error if parsing fails
func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
// Remove timezone name in parentheses if present
dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "")
// Try parsing with common email date formats
formats := []string{
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700",
}
for _, format := range formats {
if parsedTime, err := time.Parse(format, dateStr); err == nil {
return parsedTime, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr)
}
// isNoReplyAddress checks if a header check represents a no-reply email address
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
if !headerCheck.Present || headerCheck.Value == nil {
return false
}
value := strings.ToLower(*headerCheck.Value)
noReplyPatterns := []string{
"no-reply",
"noreply",
"ne-pas-repondre",
"nepasrepondre",
}
for _, pattern := range noReplyPatterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
// validateAddressHeader validates email address header using net/mail parser
// and returns the normalized address string in "Name <email>" format
func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) {
// Try to parse as a single address first
if addr, err := mail.ParseAddress(value); err == nil {
return h.formatAddress(addr), nil
}
// If single address parsing fails, try parsing as an address list
// (for headers like To, Cc that can contain multiple addresses)
if addrs, err := mail.ParseAddressList(value); err != nil {
return "", err
} else {
// Join multiple addresses with ", "
result := ""
for i, addr := range addrs {
if i > 0 {
result += ", "
}
result += h.formatAddress(addr)
}
return result, nil
}
}
// formatAddress formats a mail.Address as "Name <email>" or just "email" if no name
func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
if addr.Name != "" {
return fmt.Sprintf("%s <%s>", addr.Name, addr.Address)
}
return addr.Address
}
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
if email == nil {
return nil
}
analysis := &api.HeaderAnalysis{}
// Check for proper MIME structure
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
// Initialize headers map
headers := make(map[string]api.HeaderCheck)
// Check required headers
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
for _, headerName := range requiredHeaders {
check := h.checkHeader(email, headerName, "required")
headers[strings.ToLower(headerName)] = *check
}
// Check recommended headers
recommendedHeaders := []string{}
if h.isNoReplyAddress(headers["from"]) {
recommendedHeaders = append(recommendedHeaders, "reply-to")
}
for _, headerName := range recommendedHeaders {
check := h.checkHeader(email, headerName, "recommended")
headers[strings.ToLower(headerName)] = *check
}
// Check MIME-Version header (recommended but absence is not penalized)
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
// Check optional headers
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
for _, headerName := range optionalHeaders {
check := h.checkHeader(email, headerName, "newsletter")
headers[strings.ToLower(headerName)] = *check
}
analysis.Headers = &headers
// Received chain
receivedChain := h.parseReceivedChain(email)
if len(receivedChain) > 0 {
analysis.ReceivedChain = &receivedChain
}
// Domain alignment
domainAlignment := h.analyzeDomainAlignment(email, authResults)
if domainAlignment != nil {
analysis.DomainAlignment = domainAlignment
}
// Header issues
issues := h.findHeaderIssues(email)
if len(issues) > 0 {
analysis.Issues = &issues
}
return analysis
}
// checkHeader checks if a header is present and valid
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
value := email.GetHeaderValue(headerName)
present := email.HasHeader(headerName) && value != ""
importanceEnum := api.HeaderCheckImportance(importance)
check := &api.HeaderCheck{
Present: present,
Importance: &importanceEnum,
}
if present {
check.Value = &value
// Validate specific headers
valid := true
var headerIssues []string
switch headerName {
case "Message-ID":
if !h.isValidMessageID(value) {
valid = false
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
}
if len(email.Header["Message-Id"]) > 1 {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
}
case "Date":
// Validate date format
if _, err := h.parseEmailDate(value); err != nil {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
}
case "MIME-Version":
if value != "1.0" {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
}
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
// Parse address header using net/mail and get normalized address
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err))
} else {
// Use the normalized address as the value
check.Value = &normalizedAddr
}
}
check.Valid = &valid
if len(headerIssues) > 0 {
check.Issues = &headerIssues
}
} else {
valid := false
check.Valid = &valid
if importance == "required" {
issues := []string{"Required header is missing"}
check.Issues = &issues
}
}
return check
}
// 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),
}
// Extract From domain
fromAddr := email.GetHeaderValue("From")
if fromAddr != "" {
domain := h.extractDomain(fromAddr)
if domain != "" {
alignment.FromDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
alignment.FromOrgDomain = &orgDomain
}
}
// Extract Return-Path domain
returnPath := email.GetHeaderValue("Return-Path")
if returnPath != "" {
domain := h.extractDomain(returnPath)
if domain != "" {
alignment.ReturnPathDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
alignment.ReturnPathOrgDomain = &orgDomain
}
}
// Extract DKIM domains from authentication results
var dkimDomains []api.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{
Domain: domain,
OrgDomain: orgDomain,
})
}
}
}
if len(dkimDomains) > 0 {
alignment.DkimDomains = &dkimDomains
}
// Check alignment (strict and relaxed)
issues := []string{}
// hasReturnPath and hasDKIM track whether we have these fields to check
hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil
hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0
// If neither Return-Path nor DKIM is present, keep default alignment (true)
// Otherwise, at least one must be aligned for overall alignment to be true
strictAligned := !hasReturnPath && !hasDKIM
relaxedAligned := !hasReturnPath && !hasDKIM
// Check Return-Path alignment
rpStrictAligned := false
rpRelaxedAligned := false
if hasReturnPath {
fromDomain := *alignment.FromDomain
rpDomain := *alignment.ReturnPathDomain
// Strict alignment: exact match (case-insensitive)
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
// Relaxed alignment: organizational domain match
var fromOrgDomain, rpOrgDomain string
if alignment.FromOrgDomain != nil {
fromOrgDomain = *alignment.FromOrgDomain
}
if alignment.ReturnPathOrgDomain != nil {
rpOrgDomain = *alignment.ReturnPathOrgDomain
}
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
if !rpStrictAligned {
if rpRelaxedAligned {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain))
} else {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain))
}
}
strictAligned = rpStrictAligned
relaxedAligned = rpRelaxedAligned
}
// Check DKIM alignment
dkimStrictAligned := false
dkimRelaxedAligned := false
if hasDKIM {
fromDomain := *alignment.FromDomain
var fromOrgDomain string
if alignment.FromOrgDomain != nil {
fromOrgDomain = *alignment.FromOrgDomain
}
for _, dkimDomain := range dkimDomains {
// Check strict alignment for this DKIM signature
if strings.EqualFold(fromDomain, dkimDomain.Domain) {
dkimStrictAligned = true
}
// Check relaxed alignment for this DKIM signature
if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) {
dkimRelaxedAligned = true
}
}
if !dkimStrictAligned && !dkimRelaxedAligned {
// List all DKIM domains that failed alignment
dkimDomainsList := []string{}
for _, dkimDomain := range dkimDomains {
dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain)
}
issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain))
} else if !dkimStrictAligned && dkimRelaxedAligned {
// DKIM has relaxed alignment but not strict
issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain))
}
// Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned
// For DMARC compliance, at least one of SPF or DKIM must be aligned
if dkimStrictAligned {
strictAligned = true
}
if dkimRelaxedAligned {
relaxedAligned = true
}
}
*alignment.Aligned = strictAligned
*alignment.RelaxedAligned = relaxedAligned
if len(issues) > 0 {
alignment.Issues = &issues
}
return alignment
}
// extractDomain extracts domain from email address
func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
// Remove angle brackets if present
emailAddr = strings.Trim(emailAddr, "<> ")
// Find @ symbol
atIndex := strings.LastIndex(emailAddr, "@")
if atIndex == -1 {
return ""
}
domain := emailAddr[atIndex+1:]
// Remove any trailing >
domain = strings.TrimRight(domain, ">")
return domain
}
// 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 {
domain = strings.ToLower(strings.TrimSpace(domain))
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
// This correctly handles cases like .co.uk, .com.au, etc.
etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
// Fallback to simple two-label extraction if PSL lookup fails
labels := strings.Split(domain, ".")
if len(labels) <= 2 {
return domain
}
return strings.Join(labels[len(labels)-2:], ".")
}
return etldPlusOne
}
// findHeaderIssues identifies issues with headers
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
var issues []api.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{
Header: header,
Severity: api.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)),
})
}
}
// Check Message-ID format
messageID := email.GetHeaderValue("Message-ID")
if messageID != "" && !h.isValidMessageID(messageID) {
issues = append(issues, api.HeaderIssue{
Header: "Message-ID",
Severity: api.HeaderIssueSeverityMedium,
Message: "Message-ID format is invalid",
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
})
}
return issues
}
// parseReceivedChain extracts the chain of Received headers from an email
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
if email == nil || email.Header == nil {
return nil
}
receivedHeaders := email.Header["Received"]
if len(receivedHeaders) == 0 {
return nil
}
var chain []api.ReceivedHop
for _, receivedValue := range receivedHeaders {
hop := h.parseReceivedHeader(receivedValue)
if hop != nil {
chain = append(chain, *hop)
}
}
return chain
}
// parseReceivedHeader parses a single Received header value
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
hop := &api.ReceivedHop{}
// Normalize whitespace - Received headers can span multiple lines
normalized := strings.Join(strings.Fields(receivedValue), " ")
// Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)")
// vs standard "from-first" header (e.g., "from hostname ... by hostname")
isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized))
// Extract "from" field - only if not in "by-first" format
// Avoid matching "from" inside parentheses after "by"
if !isByFirst {
fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`)
if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 {
from := matches[1]
hop.From = &from
}
}
// Extract "by" field
byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`)
if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 {
by := matches[1]
hop.By = &by
}
// Extract "with" field (protocol) - must come after "by" and before "id" or "for"
// This ensures we get the mail transfer protocol, not other "with" occurrences
// Avoid matching "with" inside parentheses (like in TLS details)
withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`)
if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 {
with := matches[1]
hop.With = &with
}
// Extract "id" field - should come after "with" or "by", not inside parentheses
// Match pattern: "id <value>" where value doesn't contain parentheses or semicolons
idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`)
if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 {
id := matches[1]
hop.Id = &id
}
// Extract IP address from parentheses after "from"
// Pattern: from hostname (anything [IPv4/IPv6])
ipRegex := regexp.MustCompile(`\[([^\]]+)\]`)
if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 {
ipStr := matches[1]
// Handle IPv6: prefix (some MTAs include this)
ipStr = strings.TrimPrefix(ipStr, "IPv6:")
// Check if it's a valid IP (IPv4 or IPv6)
if net.ParseIP(ipStr) != nil {
hop.Ip = &ipStr
// Perform reverse DNS lookup
if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 {
// Remove trailing dot from PTR record
reverse := strings.TrimSuffix(reverseNames[0], ".")
hop.Reverse = &reverse
}
}
}
// Extract timestamp - usually at the end after semicolon
// Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)"
timestampRegex := regexp.MustCompile(`;\s*(.+)$`)
if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 {
timestampStr := strings.TrimSpace(matches[1])
// Use the dedicated date parsing function
if parsedTime, err := h.parseEmailDate(timestampStr); err == nil {
hop.Timestamp = &parsedTime
}
}
return hop
}

1079
pkg/analyzer/headers_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -28,9 +28,16 @@ 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
@ -211,8 +218,27 @@ 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 {
return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
// If no hostname specified, return all results
if hostname == "" {
return allResults
}
// Filter results that begin with the specified hostname
var filtered []string
prefix := hostname + ";"
for _, result := range allResults {
// Trim whitespace and check if it starts with hostname;
trimmed := strings.TrimSpace(result)
if strings.HasPrefix(trimmed, prefix) {
filtered = append(filtered, result)
}
}
return filtered
}
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
@ -230,6 +256,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
}
for _, headerName := range saHeaders {
if values, ok := e.Header[headerName]; ok && len(values) > 0 {
for _, value := range values {
if strings.TrimSpace(value) != "" {
headers[headerName] = value
break
}
}
} else if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value
}
}
return headers
}
// GetRspamdHeaders extracts rspamd-related headers
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
headers := make(map[string]string)
rspamdHeaders := []string{
"X-Spamd-Result",
"X-Rspamd-Score",
"X-Rspamd-Action",
"X-Rspamd-Server",
}
for _, headerName := range rspamdHeaders {
if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value
}
@ -275,3 +328,20 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
func (e *EmailMessage) HasHeader(key string) bool {
return e.Header.Get(key) != ""
}
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
// The header format is: <url1>, <url2>, ...
func (e *EmailMessage) GetListUnsubscribeURLs() []string {
value := e.Header.Get("List-Unsubscribe")
if value == "" {
return nil
}
var urls []string
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") {
urls = append(urls, part[1:len(part)-1])
}
}
return urls
}

View file

@ -106,6 +106,9 @@ 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

346
pkg/analyzer/rbl.go Normal file
View file

@ -0,0 +1,346 @@
// 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 (
"context"
"fmt"
"net"
"regexp"
"strings"
"sync"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
type DNSListChecker struct {
Timeout time.Duration
Lists []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
resolver *net.Resolver
informationalSet map[string]bool // Lists whose hits don't count toward the score
}
// DefaultRBLs is a list of commonly used RBL providers
var DefaultRBLs = []string{
"zen.spamhaus.org", // Spamhaus combined list
"bl.spamcop.net", // SpamCop
"dnsbl.sorbs.net", // SORBS
"b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
"psbl.surriel.com", // PSBL
"dnsbl.dronebl.org", // DroneBL
"bl.mailspike.net", // Mailspike BL
"z.mailspike.net", // Mailspike Z
"bl.rbl-dns.com", // RBL-DNS
"bl.nszones.com", // NSZones
}
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
// These are typically broader lists where being listed is less definitive.
var DefaultInformationalRBLs = []string{
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
}
// DefaultDNSWLs is a list of commonly used DNSWL providers
var DefaultDNSWLs = []string{
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
"swl.spamhaus.org", // Spamhaus Safe Whitelist
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
timeout = 5 * time.Second
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
for _, rbl := range DefaultInformationalRBLs {
informationalSet[rbl] = true
}
return &DNSListChecker{
Timeout: timeout,
Lists: rbls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: true,
resolver: &net.Resolver{PreferGo: true},
informationalSet: informationalSet,
}
}
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
timeout = 5 * time.Second
}
if len(dnswls) == 0 {
dnswls = DefaultDNSWLs
}
return &DNSListChecker{
Timeout: timeout,
Lists: dnswls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: false,
resolver: &net.Resolver{PreferGo: true},
informationalSet: make(map[string]bool),
}
}
// 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
IPsChecked []string
ListedCount int // Total listings including informational entries
RelevantListedCount int // Listings on scoring (non-informational) lists only
}
// CheckEmail checks all IPs found in the email headers against the configured lists
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
results := &DNSListResults{
Checks: make(map[string][]api.BlacklistCheck),
}
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
}
results.IPsChecked = ips
for _, ip := range ips {
for _, list := range r.Lists {
check := r.checkIP(ip, list)
results.Checks[ip] = append(results.Checks[ip], check)
if check.Listed {
results.ListedCount++
if !r.informationalSet[list] {
results.RelevantListedCount++
}
}
}
if !r.CheckAllIPs {
break
}
}
return results
}
// CheckIP checks a single IP address against all configured lists in parallel
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
if !r.isPublicIP(ip) {
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
}
checks := make([]api.BlacklistCheck, len(r.Lists))
var wg sync.WaitGroup
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++
}
}
return checks, listedCount, nil
}
// extractIPs extracts IP addresses from Received headers
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
receivedHeaders := email.Header["Received"]
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
for _, received := range receivedHeaders {
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
if !r.isPublicIP(match) {
continue
}
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
}
}
}
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
ips = append(ips, matches)
}
}
}
return ips
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
if ip.IsUnspecified() {
return false
}
return true
}
// checkIP checks a single IP against a single DNS list
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
check := api.BlacklistCheck{
Rbl: list,
}
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = api.PtrTo("Failed to reverse IP address")
return check
}
query := fmt.Sprintf("%s.%s", reversedIP, list)
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
return check
}
if len(addrs) > 0 {
check.Response = api.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]))
} else {
check.Listed = true
}
}
return check
}
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
// Example: 192.0.2.1 -> 1.2.0.192
func (r *DNSListChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// CalculateScore calculates the list contribution to deliverability.
// Informational lists are not counted in the score.
func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) {
if results == nil || len(results.IPsChecked) == 0 {
return 100, ""
}
scoringListCount := len(r.Lists) - len(r.informationalSet)
if scoringListCount <= 0 {
return 100, "A+"
}
percentage := 100 - results.RelevantListedCount*100/scoringListCount
return percentage, ScoreToGrade(percentage)
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
var listedIPs []string
for ip, checks := range results.Checks {
for _, check := range checks {
if check.Listed {
listedIPs = append(listedIPs, ip)
break
}
}
}
return listedIPs
}
// GetListsForIP returns all lists that match a specific IP
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
var lists []string
if checks, exists := results.Checks[ip]; exists {
for _, check := range checks {
if check.Listed {
lists = append(lists, check.Rbl)
}
}
}
return lists
}

View file

@ -23,7 +23,6 @@ package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
@ -56,12 +55,12 @@ func TestNewRBLChecker(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := NewRBLChecker(tt.timeout, tt.rbls)
checker := NewRBLChecker(tt.timeout, tt.rbls, false)
if checker.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
}
if len(checker.RBLs) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
if len(checker.Lists) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
}
if checker.resolver == nil {
t.Error("Resolver should not be nil")
@ -98,7 +97,7 @@ func TestReverseIP(t *testing.T) {
},
}
checker := NewRBLChecker(5*time.Second, nil)
checker := NewRBLChecker(5*time.Second, nil, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -158,7 +157,7 @@ func TestIsPublicIP(t *testing.T) {
},
}
checker := NewRBLChecker(5*time.Second, nil)
checker := NewRBLChecker(5*time.Second, nil, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -238,7 +237,7 @@ func TestExtractIPs(t *testing.T) {
},*/
}
checker := NewRBLChecker(5*time.Second, nil)
checker := NewRBLChecker(5*time.Second, nil, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -266,68 +265,68 @@ func TestExtractIPs(t *testing.T) {
func TestGetBlacklistScore(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedScore float32
results *DNSListResults
expectedScore int
}{
{
name: "Nil results",
results: nil,
expectedScore: 2.0,
expectedScore: 100,
},
{
name: "No IPs checked",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{},
},
expectedScore: 2.0,
expectedScore: 100,
},
{
name: "Not listed on any RBL",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
},
expectedScore: 2.0,
expectedScore: 100,
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
},
expectedScore: 1.0,
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
},
expectedScore: 0.5,
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
},
{
name: "Listed on 3 RBLs",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 3,
},
expectedScore: 0.5,
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
},
expectedScore: 0.0,
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
},
}
checker := NewRBLChecker(5*time.Second, nil)
checker := NewRBLChecker(5*time.Second, nil, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := checker.GetBlacklistScore(tt.results)
score, _ := checker.CalculateScore(tt.results)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@ -335,215 +334,24 @@ func TestGetBlacklistScore(t *testing.T) {
}
}
func TestGenerateSummaryCheck(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Not listed",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: make([]RBLCheck, 6), // 6 default RBLs
},
expectedStatus: api.CheckStatusPass,
expectedScore: 2.0,
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 1.0,
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateSummaryCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
})
}
}
func TestGenerateListingCheck(t *testing.T) {
tests := []struct {
name string
rblCheck *RBLCheck
expectedStatus api.CheckStatus
expectedSeverity api.CheckSeverity
}{
{
name: "Spamhaus listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "zen.spamhaus.org",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.Critical,
},
{
name: "SpamCop listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "bl.spamcop.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.High,
},
{
name: "Other RBL listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "dnsbl.sorbs.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.High,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateListingCheck(tt.rblCheck)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
}
})
}
}
func TestGenerateRBLChecks(t *testing.T) {
tests := []struct {
name string
results *RBLResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "No IPs checked",
results: &RBLResults{
IPsChecked: []string{},
},
minChecks: 1, // Warning check
},
{
name: "Not listed on any RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
},
},
minChecks: 1, // Summary check only
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
},
},
minChecks: 3, // Summary + 2 listing checks
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := checker.GenerateRBLChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Blacklist category
for _, check := range checks {
if check.Category != api.Blacklist {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
}
}
})
}
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &RBLResults{
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
{IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},
},
"198.51.100.2": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: false},
},
"198.51.100.3": {
{Rbl: "zen.spamhaus.org", Listed: false},
},
},
}
checker := NewRBLChecker(5*time.Second, nil)
checker := NewRBLChecker(5*time.Second, nil, false)
listedIPs := checker.GetUniqueListedIPs(results)
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
@ -555,16 +363,20 @@ func TestGetUniqueListedIPs(t *testing.T) {
}
func TestGetRBLsForIP(t *testing.T) {
results := &RBLResults{
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},
{Rbl: "dnsbl.sorbs.net", Listed: false},
},
"198.51.100.2": {
{Rbl: "zen.spamhaus.org", Listed: true},
},
},
}
checker := NewRBLChecker(5*time.Second, nil)
checker := NewRBLChecker(5*time.Second, nil, false)
tests := []struct {
name string
@ -590,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rbls := checker.GetRBLsForIP(results, tt.ip)
rbls := checker.GetListsForIP(results, tt.ip)
if len(rbls) != len(tt.expectedRBLs) {
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))

306
pkg/analyzer/report.go Normal file
View file

@ -0,0 +1,306 @@
// 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 (
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
// ReportGenerator generates comprehensive deliverability reports
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *DNSListChecker
dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
dnswls []string,
checkAllIPs bool,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
Content *ContentResults
DNS *api.DNSResults
Headers *api.HeaderAnalysis
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
}
// AnalyzeEmail performs complete email analysis
func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
results := &AnalysisResults{
Email: email,
}
// 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)
results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
return results
}
// GenerateReport creates a complete API report from analysis results
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
reportID := uuid.New()
now := time.Now()
report := &api.Report{
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
CreatedAt: now,
}
// Calculate scores directly from analyzers (no more checks array)
dnsScore := 0
var dnsGrade string
if results.DNS != nil {
// Extract sender IP from received chain for FCrDNS verification
var senderIP string
if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 {
firstHop := (*results.Headers.ReceivedChain)[0]
if firstHop.Ip != nil {
senderIP = *firstHop.Ip
}
}
dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP)
}
authScore := 0
var authGrade string
if results.Authentication != nil {
authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
}
contentScore := 0
var contentGrade string
if results.Content != nil {
contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
}
headerScore := 0
var headerGrade rune
if results.Headers != nil {
headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
}
blacklistScore := 0
var blacklistGrade string
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
}
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
// Combine SpamAssassin and rspamd scores 50/50.
// If only one filter ran (the other returns "" grade), use that filter's score alone.
var spamScore int
var spamGrade string
switch {
case saGrade == "" && rspamdGrade == "":
spamScore = 0
spamGrade = ""
case saGrade == "":
spamScore = rspamdScore
spamGrade = rspamdGrade
case rspamdGrade == "":
spamScore = saScore
spamGrade = saGrade
default:
spamScore = (saScore + rspamdScore) / 2
spamGrade = MinGrade(saGrade, rspamdGrade)
}
report.Summary = &api.ScoreSummary{
DnsScore: dnsScore,
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore,
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore,
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
ContentScore: contentScore,
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore,
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore,
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
}
// Add authentication results
report.Authentication = results.Authentication
// Add content analysis
if results.Content != nil {
contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
report.ContentAnalysis = contentAnalysis
}
// Add DNS records
if results.DNS != nil {
report.DnsResults = results.DNS
}
// Add headers results
report.HeaderAnalysis = results.Headers
// Add blacklist checks as a map of IP -> array of BlacklistCheck
if results.RBL != nil && len(results.RBL.Checks) > 0 {
report.Blacklists = &results.RBL.Checks
}
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
report.Whitelists = &results.DNSWL.Checks
}
// Add SpamAssassin result with individual deliverability score
if results.SpamAssassin != nil {
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = api.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)
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
}
report.Rspamd = results.Rspamd
// Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders
}
// Calculate overall score as mean of all category scores
categoryScores := []int{
report.Summary.DnsScore,
report.Summary.AuthenticationScore,
report.Summary.BlacklistScore,
report.Summary.ContentScore,
report.Summary.HeaderScore,
report.Summary.SpamScore,
}
var totalScore int
var categoryCount int
for _, score := range categoryScores {
totalScore += score
categoryCount++
}
if categoryCount > 0 {
report.Score = totalScore / categoryCount
} else {
report.Score = 0
}
report.Grade = ScoreToReportGrade(report.Score)
categoryGrades := []string{
string(report.Summary.DnsGrade),
string(report.Summary.AuthenticationGrade),
string(report.Summary.BlacklistGrade),
string(report.Summary.ContentGrade),
string(report.Summary.HeaderGrade),
string(report.Summary.SpamGrade),
}
if report.Score >= 100 {
hasLessThanA := false
for _, grade := range categoryGrades {
if len(grade) < 1 || grade[0] != 'A' {
hasLessThanA = true
}
}
if !hasLessThanA {
report.Grade = "A+"
}
} else {
var minusGrade byte = 0
for _, grade := range categoryGrades {
if len(grade) == 0 {
minusGrade = 255
break
} else if grade[0]-'A' > minusGrade {
minusGrade = grade[0] - 'A'
}
}
if minusGrade < 255 {
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
}
}
return report
}
// GenerateRawEmail returns the raw email message as a string
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
if email == nil {
return ""
}
raw := email.RawHeaders
if email.RawBody != "" {
raw += "\n" + email.RawBody
}
return raw
}

228
pkg/analyzer/report_test.go Normal file
View file

@ -0,0 +1,228 @@
// 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 (
"net/mail"
"net/textproto"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
func TestNewReportGenerator(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
if gen.authAnalyzer == nil {
t.Error("authAnalyzer should not be nil")
}
if gen.spamAnalyzer == nil {
t.Error("spamAnalyzer should not be nil")
}
if gen.dnsAnalyzer == nil {
t.Error("dnsAnalyzer should not be nil")
}
if gen.rblChecker == nil {
t.Error("rblChecker should not be nil")
}
if gen.contentAnalyzer == nil {
t.Error("contentAnalyzer should not be nil")
}
}
func TestAnalyzeEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
email := createTestEmail()
results := gen.AnalyzeEmail(email)
if results == nil {
t.Fatal("Expected analysis results, got nil")
}
if results.Email == nil {
t.Error("Email should not be nil")
}
if results.Authentication == nil {
t.Error("Authentication should not be nil")
}
}
func TestGenerateReport(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
testID := uuid.New()
email := createTestEmail()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
if report == nil {
t.Fatal("Expected report, got nil")
}
// Verify required fields
if report.Id == "" {
t.Error("Report ID should not be empty")
}
// Convert testID to base32 for comparison
expectedTestID := utils.UUIDToBase32(testID)
if report.TestId != expectedTestID {
t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID)
}
if report.Score < 0 || report.Score > 100 {
t.Errorf("Score %v is out of bounds", report.Score)
}
if report.Summary == nil {
t.Error("Summary should not be nil")
}
// Verify score summary (all scores are 0-100 percentages)
if report.Summary != nil {
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 {
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
}
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 {
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
}
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 {
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
}
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 {
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
}
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 {
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
}
if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 {
t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore)
}
}
}
func TestGenerateReportWithSpamAssassin(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
if report.Spamassassin == nil {
t.Error("SpamAssassin result should not be nil")
}
if report.Spamassassin != nil {
if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 {
t.Error("SpamAssassin scores should be set")
}
}
}
func TestGenerateRawEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
tests := []struct {
name string
email *EmailMessage
expected string
}{
{
name: "Nil email",
email: nil,
expected: "",
},
{
name: "Email with headers only",
email: &EmailMessage{
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n",
RawBody: "",
},
expected: "From: sender@example.com\nTo: recipient@example.com\n",
},
{
name: "Email with headers and body",
email: &EmailMessage{
RawHeaders: "From: sender@example.com\n",
RawBody: "This is the email body",
},
expected: "From: sender@example.com\n\nThis is the email body",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := gen.GenerateRawEmail(tt.email)
if raw != tt.expected {
t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected)
}
})
}
}
// Helper functions
func createTestEmail() *EmailMessage {
header := make(mail.Header)
header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"}
header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"}
header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"}
header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"}
header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{"<test123@example.com>"}
return &EmailMessage{
Header: header,
From: &mail.Address{Address: "sender@example.com"},
To: []*mail.Address{{Address: "recipient@example.com"}},
Subject: "Test Email",
MessageID: "<test123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{
{
ContentType: "text/plain",
Content: "This is a test email",
IsText: true,
},
},
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: <test123@example.com>\n",
RawBody: "This is a test email",
}
}
func createTestEmailWithSpamAssassin() *EmailMessage {
email := createTestEmail()
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"}
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"}
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
return email
}

155
pkg/analyzer/rspamd.go Normal file
View file

@ -0,0 +1,155 @@
// 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 (
"math"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// Default rspamd action thresholds (rspamd built-in defaults)
const (
rspamdDefaultRejectThreshold float32 = 15
rspamdDefaultAddHeaderThreshold float32 = 6
)
// RspamdAnalyzer analyzes rspamd results from email headers
type RspamdAnalyzer struct{}
// NewRspamdAnalyzer creates a new rspamd analyzer
func NewRspamdAnalyzer() *RspamdAnalyzer {
return &RspamdAnalyzer{}
}
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
headers := email.GetRspamdHeaders()
if len(headers) == 0 {
return nil
}
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
}
// 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)
}
// Parse X-Rspamd-Score as override/fallback for score
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
result.Score = float32(score)
}
}
// Parse X-Rspamd-Server
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
server := strings.TrimSpace(serverHeader)
result.Server = &server
}
// Derive IsSpam from score vs reject threshold.
if result.Threshold > 0 {
result.IsSpam = result.Score >= result.Threshold
} else {
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
}
return result
}
// 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) {
// 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*\]`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.Score = float32(score)
}
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
result.Threshold = float32(threshold)
// No threshold? use default AddHeaderThreshold
if result.Threshold <= 0 {
result.Threshold = rspamdDefaultAddHeaderThreshold
}
}
}
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
firstLine := strings.SplitN(header, ";", 2)[0]
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
result.IsSpam = true
}
// Parse symbols: SYMBOL(score)[params]
// 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{
Name: name,
Score: float32(score),
}
if len(matches) > 3 && matches[3] != "" {
params := matches[3]
sym.Params = &params
}
result.Symbols[name] = sym
}
}
}
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
if result == nil {
return 100, "" // rspamd not installed
}
threshold := result.Threshold
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
if percentage > 100 {
return 100, "A+"
} else if percentage < 0 {
return 0, "F"
}
// Linear scale between 0 and threshold
return percentage, ScoreToGrade(percentage)
}

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

@ -0,0 +1,414 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"bytes"
"net/mail"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
analyzer := NewRspamdAnalyzer()
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()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
}
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()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{Header: make(mail.Header)}
for k, v := range tt.headers {
email.Header[k] = []string{v}
}
result := analyzer.AnalyzeRspamd(email)
if result == nil {
t.Fatal("Expected non-nil result")
}
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
if tt.expectedServer != nil {
if result.Server == nil {
t.Errorf("Server = nil, want %q", *tt.expectedServer)
} else if *result.Server != *tt.expectedServer {
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
}
}
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
}
})
}
}
func TestCalculateRspamdScore(t *testing.T) {
tests := []struct {
name string
result *api.RspamdResult
expectedScore int
expectedGrade string
}{
{
name: "Nil result (rspamd not installed)",
result: nil,
expectedScore: 100,
expectedGrade: "",
},
{
name: "Score well below threshold",
result: &api.RspamdResult{
Score: -3.91,
Threshold: 15.00,
},
expectedScore: 100,
expectedGrade: "A+",
},
{
name: "Score at zero",
result: &api.RspamdResult{
Score: 0,
Threshold: 15.00,
},
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
expectedScore: 100,
expectedGrade: "A",
},
{
name: "Score at threshold (half of 2*threshold)",
result: &api.RspamdResult{
Score: 15.00,
Threshold: 15.00,
},
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
expectedScore: 50,
},
{
name: "Score above 2*threshold",
result: &api.RspamdResult{
Score: 31.00,
Threshold: 15.00,
},
expectedScore: 0,
expectedGrade: "F",
},
{
name: "Score exactly at 2*threshold",
result: &api.RspamdResult{
Score: 30.00,
Threshold: 15.00,
},
// 100 - round(30*100/30) = 100 - 100 = 0
expectedScore: 0,
expectedGrade: "F",
},
}
analyzer := NewRspamdAnalyzer()
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()
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)
}
}

99
pkg/analyzer/scoring.go Normal file
View file

@ -0,0 +1,99 @@
// 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 (
"git.happydns.org/happyDeliver/internal/api"
)
// ScoreToGrade converts a percentage score (0-100) to a letter grade
func ScoreToGrade(score int) string {
switch {
case score > 100:
return "A+"
case score >= 95:
return "A"
case score >= 85:
return "B"
case score >= 75:
return "C"
case score >= 65:
return "D"
case score >= 50:
return "E"
default:
return "F"
}
}
// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation
func ScoreToGradeKind(score int) string {
switch {
case score > 100:
return "A+"
case score >= 90:
return "A"
case score >= 80:
return "B"
case score >= 60:
return "C"
case score >= 45:
return "D"
case score >= 30:
return "E"
default:
return "F"
}
}
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
func ScoreToReportGrade(score int) api.ReportGrade {
return api.ReportGrade(ScoreToGrade(score))
}
// gradeRank returns a numeric rank for a grade (lower = worse)
func gradeRank(grade string) int {
switch grade {
case "A+":
return 6
case "A":
return 5
case "B":
return 4
case "C":
return 3
case "D":
return 2
case "E":
return 1
default:
return 0
}
}
// MinGrade returns the minimal (worse) grade between the two given grades
func MinGrade(a, b string) string {
if gradeRank(a) <= gradeRank(b) {
return a
}
return b
}

View file

@ -0,0 +1,220 @@
// 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 (
"math"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
type SpamAssassinAnalyzer struct{}
// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer
func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
return &SpamAssassinAnalyzer{}
}
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
}
// Parse X-Spam-Status header
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
a.parseSpamStatus(statusHeader, result)
}
// Parse X-Spam-Score header (as fallback if not in X-Spam-Status)
if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
result.Score = float32(score)
}
}
// Parse X-Spam-Flag header (as fallback)
if flagHeader, ok := headers["X-Spam-Flag"]; ok {
result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES"
}
// 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))
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))
}
return result
}
// 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) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
firstPart := strings.TrimSpace(parts[0])
result.IsSpam = strings.EqualFold(firstPart, "yes")
}
// Extract score
scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.Score = float32(score)
}
}
// Extract required score
requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`)
if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 {
if required, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.RequiredScore = float32(required)
}
}
// Extract tests
testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`)
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
testsStr := matches[1]
// Tests can be comma or space separated
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
return r == ',' || r == ' '
})
result.Tests = &tests
}
}
// parseSpamReport parses the X-Spam-Report header to extract test details
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
// Multiline descriptions continue on lines starting with * but without score:
// * 0.0 TEST_NAME Description line 1
// * continuation line 2
// * continuation line 3
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
segments := strings.Split(report, "*")
// Regex to match test lines: score TEST_NAME Description
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
var currentTestName string
var currentDescription strings.Builder
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
// Try to match as a test line
matches := testRe.FindStringSubmatch(segment)
if len(matches) > 3 {
// Save previous test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := api.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
}
result.TestDetails[currentTestName] = detail
}
// Start new test
testName := matches[2]
score, _ := strconv.ParseFloat(matches[1], 64)
description := strings.TrimSpace(matches[3])
currentTestName = testName
currentDescription.Reset()
currentDescription.WriteString(description)
// Initialize with score
result.TestDetails[testName] = api.SpamTestDetail{
Name: testName,
Score: float32(score),
}
} else if currentTestName != "" {
// This is a continuation line for the current test
// Add a space before appending to ensure proper word separation
if currentDescription.Len() > 0 {
currentDescription.WriteString(" ")
}
currentDescription.WriteString(segment)
}
}
// Save the last test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := api.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
}
result.TestDetails[currentTestName] = detail
}
}
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
if result == nil {
return 100, "" // No spam scan results, assume good
}
// SpamAssassin score typically ranges from -10 to +20
// Score < 0 is very likely ham (good)
// Score 0-5 is threshold range (configurable, usually 5.0)
// Score > 5 is likely spam
score := result.Score
// Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
if score < 0 {
return 100, "A+" // Perfect score for ham
} else if score == 0 {
return 100, "A" // Perfect score for ham
} else if score >= result.RequiredScore {
return 0, "F" // Failed spam test
} else {
// Linear scale between 0 and required threshold
percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore))))
return percentage, ScoreToGrade(percentage - 5)
}
}

View file

@ -22,6 +22,7 @@
package analyzer
import (
"bytes"
"net/mail"
"strings"
"testing"
@ -34,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) {
name string
header string
expectedIsSpam bool
expectedScore float64
expectedReq float64
expectedScore float32
expectedReq float32
expectedTests []string
}{
{
@ -76,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@ -90,8 +91,12 @@ func TestParseSpamStatus(t *testing.T) {
if result.RequiredScore != tt.expectedReq {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq)
}
if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) {
t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests)
if len(tt.expectedTests) > 0 {
if result.Tests == nil {
t.Errorf("Tests = nil, want %v", tt.expectedTests)
} else if !stringSliceEqual(*result.Tests, tt.expectedTests) {
t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests)
}
}
})
}
@ -110,27 +115,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
expectedTests := map[string]SpamTestDetail{
expectedTests := map[string]api.SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: "From address doesn't match envelope sender",
Description: api.PtrTo("From address doesn't match envelope sender"),
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
Description: "All mail servers are trusted",
Description: api.PtrTo("All mail servers are trusted"),
},
}
@ -143,8 +148,8 @@ func TestParseSpamReport(t *testing.T) {
if detail.Score != expected.Score {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score)
}
if detail.Description != expected.Description {
t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description)
if *detail.Description != *expected.Description {
t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description)
}
}
}
@ -152,56 +157,63 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedScore float32
minScore float32
maxScore float32
result *api.SpamAssassinResult
expectedScore int
minScore int
maxScore int
}{
{
name: "Nil result",
result: nil,
expectedScore: 0.0,
expectedScore: 100,
},
{
name: "Excellent score (negative)",
result: &SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
expectedScore: 2.0,
expectedScore: 100,
},
{
name: "Good score (below threshold)",
result: &SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 2.0,
RequiredScore: 5.0,
},
minScore: 1.5,
maxScore: 2.0,
expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60
},
{
name: "Borderline (just above threshold)",
result: &SpamAssassinResult{
name: "Score at threshold",
result: &api.SpamAssassinResult{
Score: 5.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
},
{
name: "Above threshold (spam)",
result: &api.SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
expectedScore: 1.0,
expectedScore: 0, // >= threshold = 0
},
{
name: "High spam score",
result: &SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
expectedScore: 0.5,
expectedScore: 0, // >= threshold = 0
},
{
name: "Very high spam score",
result: &SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},
expectedScore: 0.0,
expectedScore: 0, // >= threshold = 0
},
}
@ -209,7 +221,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.GetSpamAssassinScore(tt.result)
score, _ := analyzer.CalculateSpamAssassinScore(tt.result)
if tt.minScore > 0 || tt.maxScore > 0 {
if score < tt.minScore || score > tt.maxScore {
@ -229,7 +241,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
name string
headers map[string]string
expectedIsSpam bool
expectedScore float64
expectedScore float32
expectedHasDetails bool
}{
{
@ -295,86 +307,6 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
}
}
func TestGenerateSpamAssassinChecks(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedStatus api.CheckStatus
minChecks int
}{
{
name: "Nil result",
result: nil,
expectedStatus: api.CheckStatusWarn,
minChecks: 1,
},
{
name: "Clean email",
result: &SpamAssassinResult{
IsSpam: false,
Score: -0.5,
RequiredScore: 5.0,
Tests: []string{"ALL_TRUSTED"},
TestDetails: map[string]SpamTestDetail{
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.5,
Description: "All mail servers are trusted",
},
},
},
expectedStatus: api.CheckStatusPass,
minChecks: 2, // Main check + one test detail
},
{
name: "Spam email",
result: &SpamAssassinResult{
IsSpam: true,
Score: 15.0,
RequiredScore: 5.0,
Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
TestDetails: map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: "From address doesn't match envelope sender",
},
},
},
expectedStatus: api.CheckStatusFail,
minChecks: 3, // Main check + two significant tests
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateSpamAssassinChecks(tt.result)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Check main check (first one)
if len(checks) > 0 {
mainCheck := checks[0]
if mainCheck.Status != tt.expectedStatus {
t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
}
if mainCheck.Category != api.Spam {
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
}
}
})
}
}
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
email := &EmailMessage{
@ -388,95 +320,147 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
}
}
func TestGenerateMainSpamCheck(t *testing.T) {
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED,
SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1
X-Spam-Level:
X-Spam-Report:
* 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
* to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in sa-accredit.habeas.com]
* 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
* to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in bl.score.senderscore.com]
* 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The
* query to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in sa-trusted.bondedsender.org]
* -0.0 SPF_PASS SPF: sender matches SPF record
* 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record
* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
* 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
* valid
* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
* domain
Date: Sun, 19 Oct 2025 08:37:30 +0000
Message-ID: <aPSjR57mUnCAt7sp@happydomain.org>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
BODY`
// TestAnalyzeRealEmailExample tests the analyzer with the real example email file
func TestAnalyzeRealEmailExample(t *testing.T) {
// Parse the email using the standard net/mail package
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
// Create analyzer and analyze SpamAssassin headers
analyzer := NewSpamAssassinAnalyzer()
result := analyzer.AnalyzeSpamAssassin(email)
tests := []struct {
name string
score float64
required float64
expectedStatus api.CheckStatus
}{
{"Excellent", -1.0, 5.0, api.CheckStatusPass},
{"Good", 2.0, 5.0, api.CheckStatusPass},
{"Borderline", 6.0, 5.0, api.CheckStatusWarn},
{"High", 8.0, 5.0, api.CheckStatusWarn},
{"Very High", 15.0, 5.0, api.CheckStatusFail},
// Validate that we got a result
if result == nil {
t.Fatal("Expected SpamAssassin result, got nil")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &SpamAssassinResult{
Score: tt.score,
RequiredScore: tt.required,
}
check := analyzer.generateMainSpamCheck(result)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Message, "spam score") {
t.Error("Message should contain 'spam score'")
}
})
}
}
func TestGenerateTestCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
detail SpamTestDetail
expectedStatus api.CheckStatus
}{
{
name: "High penalty test",
detail: SpamTestDetail{
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Medium penalty test",
detail: SpamTestDetail{
Name: "HTML_MESSAGE",
Score: 1.5,
Description: "Contains HTML",
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Positive test",
detail: SpamTestDetail{
Name: "ALL_TRUSTED",
Score: -2.0,
Description: "All mail servers are trusted",
},
expectedStatus: api.CheckStatusPass,
},
// Validate IsSpam flag (should be false for this email)
if result.IsSpam {
t.Error("IsSpam should be false for real_example.eml")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTestCheck(tt.detail)
// Validate score (should be -0.1)
var expectedScore float32 = -0.1
if result.Score != expectedScore {
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
}
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Name, tt.detail.Name) {
t.Errorf("Check name should contain test name %s", tt.detail.Name)
}
})
// Validate required score (should be 5.0)
var expectedRequired float32 = 5.0
if result.RequiredScore != expectedRequired {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
}
// Validate version
if result.Version == nil {
t.Errorf("Version should contain 'SpamAssassin', got: nil")
} else if !strings.Contains(*result.Version, "SpamAssassin") {
t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version)
}
// Validate that tests were extracted
if len(*result.Tests) == 0 {
t.Error("Expected tests to be extracted, got none")
}
// Check for expected tests from the real email
expectedTests := map[string]bool{
"DKIM_SIGNED": true,
"DKIM_VALID": true,
"DKIM_VALID_AU": true,
"SPF_PASS": true,
"SPF_HELO_NONE": true,
}
for _, testName := range *result.Tests {
if expectedTests[testName] {
t.Logf("Found expected test: %s", testName)
}
}
// Validate that test details were parsed from X-Spam-Report
if len(result.TestDetails) == 0 {
t.Error("Expected test details to be parsed from X-Spam-Report, got none")
}
// Log what we actually got for debugging
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
for name, detail := range result.TestDetails {
t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description)
}
// Define expected test details with their scores
expectedTestDetails := map[string]float32{
"SPF_PASS": -0.0,
"SPF_HELO_NONE": 0.0,
"DKIM_VALID": -0.1,
"DKIM_SIGNED": 0.1,
"DKIM_VALID_AU": -0.1,
"RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0,
"RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0,
"RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0,
}
// Iterate over expected tests and verify they exist in TestDetails
for testName, expectedScore := range expectedTestDetails {
detail, ok := result.TestDetails[testName]
if !ok {
t.Errorf("Expected test %s not found in TestDetails", testName)
continue
}
if detail.Score != expectedScore {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
}
if detail.Description == nil || *detail.Description == "" {
t.Errorf("Test %s should have a description", testName)
}
}
// Test GetSpamAssassinScore
score, _ := analyzer.CalculateSpamAssassinScore(result)
if score != 100 {
t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
}
}

View file

@ -6,7 +6,7 @@ export default defineConfig({
plugins: [
{
name: "@hey-api/client-fetch",
runtimeConfigPath: "./src/lib/hey-api.ts",
runtimeConfigPath: "$lib/hey-api.ts",
},
],
});

2248
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,17 +16,17 @@
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/compat": "^2.0.0",
"@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.85.2",
"@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",
"@types/node": "^22",
"eslint": "^9.36.0",
"@types/node": "^24.0.0",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"globals": "^17.0.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",

View file

@ -23,9 +23,10 @@ package web
import (
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
@ -41,12 +42,34 @@ import (
var (
indexTpl *template.Template
CustomBodyHTML = ""
CustomHeadHTML = ""
)
func init() {
flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before </head>")
flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before </body>")
}
func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig := map[string]interface{}{}
if cfg.ReportRetention > 0 {
appConfig["report_retention"] = cfg.ReportRetention
}
if cfg.SurveyURL.Host != "" {
appConfig["survey_url"] = cfg.SurveyURL.String()
}
if len(cfg.Analysis.RBLs) > 0 {
appConfig["rbls"] = cfg.Analysis.RBLs
}
if cfg.CustomLogoURL != "" {
appConfig["custom_logo_url"] = cfg.CustomLogoURL
}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
@ -66,6 +89,12 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/", serveOrReverse("/", cfg))
router.GET("/blacklist/", serveOrReverse("/", cfg))
router.GET("/blacklist/:ip", serveOrReverse("/", cfg))
router.GET("/domain/", serveOrReverse("/", cfg))
router.GET("/domain/:domain", serveOrReverse("/", cfg))
router.GET("/test/", serveOrReverse("/", cfg))
router.GET("/test/:testid", 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))
@ -85,7 +114,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
if forced_url != "" {
if forced_url != "" && forced_url != "/" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
@ -114,14 +143,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
}
}
v, _ := ioutil.ReadAll(resp.Body)
v, _ := io.ReadAll(resp.Body)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Head": CustomHeadHTML,
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
@ -139,16 +170,18 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
v, _ := ioutil.ReadAll(f)
v, _ := io.ReadAll(f)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
}
// Serve template
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Head": CustomHeadHTML,
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}

View file

@ -1,6 +1,9 @@
:root {
--bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135;
--bs-link-color-rgb: 28, 180, 135;
--bs-link-hover-color-rgb: 17, 112, 84;
--bs-tertiary-bg: #e7e8e8;
}
body {
@ -8,6 +11,10 @@ body {
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.bg-tertiary {
background-color: var(--bs-tertiary-bg);
}
/* Animations */
@keyframes fadeIn {
from {
@ -74,14 +81,21 @@ body {
/* Custom card styling */
.card {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
.card:not(.fade-in .card) {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.fade-in .card:not(.card .card) {
border: none;
}
.card:hover:not(.fade-in .card) {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

View file

@ -3,9 +3,38 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="happyDeliver - Test Your Email Deliverability" />
<meta
property="og:description"
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
/>
<meta property="og:image" content="/img/og.webp" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="happyDeliver - Test Your Email Deliverability" />
<meta
name="twitter:description"
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
/>
<meta name="twitter:image" content="/img/og.webp" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<script>
// Apply theme before render to prevent flash
(function () {
const stored = localStorage.getItem("theme");
const theme =
stored ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-bs-theme", theme);
})();
</script>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
<marker
orient="auto"
refY="0.0"
refX="0.0"
id="Arrow1Lstart"
style="overflow:visible">
<path
id="path1110"
d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z "
style="fill-rule:evenodd;stroke:#000000;stroke-width:1.0pt"
transform="scale(0.8) translate(12.5,0)" />
</marker>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
showgrid="false"
units="px"
showborder="true"
borderlayer="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#d7b3e6;stroke-width:3.0463;stroke-dasharray:none;stroke-opacity:1"
d="M 4.744943,232.9437 62.17147,232.53602 35.338987,253.12317 Z"
id="path1" />
<rect
style="fill:#1cb487;fill-opacity:0;stroke:#c38dd9;stroke-width:6.17022181;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect819"
width="61.563114"
height="61.56311"
x="232.35176"
y="-64.648224"
ry="2.1808801"
rx="2.147301"
transform="rotate(90)" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:113.242px;line-height:125%;font-family:'Fortheenas_01 Bold';-inkscape-font-specification:'Fortheenas_01 Bold, ';letter-spacing:0px;word-spacing:0px;fill:#871cb4;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="-5.1976156"
y="308.21759"
id="text817"><tspan
sodipodi:role="line"
id="tspan815"
x="-5.1976156"
y="308.21759"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:107.597px;line-height:100%;font-family:'Fortheenas_01 Bold';-inkscape-font-specification:'Fortheenas_01 Bold, ';writing-mode:lr-tb;direction:ltr;fill:#871cb4;fill-opacity:1;stroke-width:0.264583px">h</tspan></text>
<path
style="fill:none;fill-rule:evenodd;stroke:#871cb4;stroke-width:12.70902443;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 5.0595093,228.81348 -0.02912,7.90683"
id="path1395"
sodipodi:nodetypes="cc" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1589"
width="119.36257"
height="43.404568"
x="-36.662537"
y="297.12582"
rx="0.94925886"
ry="0.91267759" />
<path
d="m 57.00432,267.54566 a 2.7691676,2.7691676 0 0 1 3.950621,3.8768 l -14.731868,18.42418 a 2.7691676,2.7691676 0 0 1 -3.987601,0.0697 l -9.769659,-9.76952 a 2.7691676,2.7691676 0 1 1 3.913781,-3.91379 l 7.731412,7.72779 12.823121,-16.33807 z"
id="path1-3"
style="fill:#67ccaf;fill-opacity:0;stroke-width:3.69222" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

Some files were not shown because too many files have changed in this diff Show more