Compare commits

..

102 commits

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

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
@ -34,7 +34,7 @@ RUN go generate ./... && \
# Stage 3: Prepare perl and spamass-milt
FROM alpine:3 AS pl
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
apk add --no-cache \
build-base \
libmilter-dev \
@ -55,7 +55,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a
perl-json-xs \
perl-list-moreutils \
perl-moose \
perl-net-idn-encode@testing \
perl-net-idn-encode@edge \
perl-net-ssleay \
perl-netaddr-ip \
perl-package-stash \
@ -86,7 +86,7 @@ RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milt
FROM alpine:3
# Install all required packages
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
apk add --no-cache \
bash \
ca-certificates \
@ -106,7 +106,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a
perl-json-xs \
perl-list-moreutils \
perl-moose \
perl-net-idn-encode@testing \
perl-net-idn-encode@edge \
perl-net-ssleay \
perl-netaddr-ip \
perl-package-stash \
@ -121,6 +121,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a
perl-xml-libxml \
postfix \
postfix-pcre \
rspamd \
spamassassin \
spamassassin-client \
supervisor \
@ -143,8 +144,11 @@ RUN mkdir -p /etc/happydeliver \
/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 mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin
&& 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
@ -154,6 +158,7 @@ RUN chmod +x /usr/local/bin/happyDeliver
COPY docker/postfix/ /etc/postfix/
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
@ -165,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"]

View file

@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a
## Features
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, 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**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
@ -26,6 +26,7 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha
- **Postfix MTA**: Receives emails on port 25
- **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
@ -37,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
@ -63,13 +64,54 @@ 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 Network and DNS
#### 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
@ -121,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:
@ -228,7 +270,7 @@ The deliverability score is calculated from A to F based on:
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
- **Blacklist**: RBL/DNSBL checks
- **Headers**: Required headers, MIME structure, Domain alignment
- **Spam**: SpamAssassin score
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
- **Content**: HTML quality, links, images, unsubscribe
## Funding

View file

@ -169,6 +169,72 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/domain:
post:
tags:
- tests
summary: Test a domain's email configuration
description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately.
operationId: testDomain
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DomainTestRequest'
responses:
'200':
description: Domain test completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DomainTestResponse'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/blacklist:
post:
tags:
- tests
summary: Check an IP address against DNS blacklists
description: Tests a single IP address (IPv4 or IPv6) against configured DNS-based blacklists (RBLs) without requiring an actual email to be sent. Returns results immediately.
operationId: checkBlacklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BlacklistCheckRequest'
responses:
'200':
description: Blacklist check completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/BlacklistCheckResponse'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/status:
get:
tags:
@ -267,6 +333,8 @@ components:
$ref: '#/components/schemas/AuthenticationResults'
spamassassin:
$ref: '#/components/schemas/SpamAssassinResult'
rspamd:
$ref: '#/components/schemas/RspamdResult'
dns_results:
$ref: '#/components/schemas/DNSResults'
blacklists:
@ -282,6 +350,19 @@ components:
listed: false
- rbl: "bl.spamcop.net"
listed: false
whitelists:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: Map of IP addresses to their DNS whitelist check results (informational only)
example:
"192.0.2.1":
- rbl: "list.dnswl.org"
listed: false
- rbl: "swl.spamhaus.org"
listed: false
content_analysis:
$ref: '#/components/schemas/ContentAnalysis'
header_analysis:
@ -335,7 +416,7 @@ components:
type: integer
minimum: 0
maximum: 100
description: SpamAssassin score (in percentage)
description: Spam filter score (SpamAssassin + rspamd combined, in percentage)
example: 15
spam_grade:
type: string
@ -598,6 +679,21 @@ components:
description: Reverse DNS (PTR record) for the IP address
example: "mail.example.com"
DKIMDomainInfo:
type: object
required:
- domain
- org_domain
properties:
domain:
type: string
description: DKIM signature domain
example: "mail.example.com"
org_domain:
type: string
description: Organizational domain extracted from DKIM domain (using Public Suffix List)
example: "example.com"
DomainAlignment:
type: object
properties:
@ -620,9 +716,8 @@ components:
dkim_domains:
type: array
items:
type: string
description: Domains from DKIM signatures
example: ["example.com"]
$ref: '#/components/schemas/DKIMDomainInfo'
description: Domains from DKIM signatures with their organizational domains
aligned:
type: boolean
description: Whether all domains align (strict alignment - exact match)
@ -694,7 +789,7 @@ components:
properties:
result:
type: string
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined]
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
description: Authentication result
example: "pass"
domain:
@ -763,6 +858,17 @@ components:
- is_spam
- test_details
properties:
deliverability_score:
type: integer
minimum: 0
maximum: 100
description: SpamAssassin deliverability score (0-100, higher is better)
example: 80
deliverability_grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade for SpamAssassin deliverability score
example: "B"
version:
type: string
description: SpamAssassin version
@ -825,6 +931,81 @@ components:
description: Human-readable description of what this test checks
example: "Bayes spam probability is 0 to 1%"
RspamdResult:
type: object
required:
- score
- threshold
- is_spam
- symbols
properties:
deliverability_score:
type: integer
minimum: 0
maximum: 100
description: rspamd deliverability score (0-100, higher is better)
example: 85
deliverability_grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade for rspamd deliverability score
example: "A"
score:
type: number
format: float
description: rspamd spam score
example: -3.91
threshold:
type: number
format: float
description: Score threshold for spam classification
example: 15.0
action:
type: string
description: rspamd action (no action, add header, rewrite subject, soft reject, reject)
example: "no action"
is_spam:
type: boolean
description: Whether message is classified as spam (action is reject or soft reject)
example: false
server:
type: string
description: rspamd server that processed the message
example: "rspamd.example.com"
symbols:
type: object
additionalProperties:
$ref: '#/components/schemas/RspamdSymbol'
description: Map of triggered rspamd symbols to their details
example:
BAYES_HAM:
name: "BAYES_HAM"
score: -1.9
params: "0.02"
report:
type: string
description: Full rspamd report (raw X-Spamd-Result header)
RspamdSymbol:
type: object
required:
- name
- score
properties:
name:
type: string
description: Symbol name
example: "BAYES_HAM"
score:
type: number
format: float
description: Score contribution of this symbol
example: -1.9
params:
type: string
description: Symbol parameters or options
example: "0.02"
DNSResults:
type: object
required:
@ -1112,3 +1293,90 @@ components:
details:
type: string
description: Additional error details
DomainTestRequest:
type: object
required:
- domain
properties:
domain:
type: string
pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
description: Domain name to test (e.g., example.com)
example: "example.com"
DomainTestResponse:
type: object
required:
- domain
- score
- grade
- dns_results
properties:
domain:
type: string
description: The tested domain name
example: "example.com"
score:
type: integer
minimum: 0
maximum: 100
description: Overall domain configuration score (0-100)
example: 85
grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade representation of the score
example: "A"
dns_results:
$ref: '#/components/schemas/DNSResults'
BlacklistCheckRequest:
type: object
required:
- ip
properties:
ip:
type: string
description: IPv4 or IPv6 address to check against blacklists
example: "192.0.2.1"
pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$'
BlacklistCheckResponse:
type: object
required:
- ip
- blacklists
- listed_count
- score
- grade
properties:
ip:
type: string
description: The IP address that was checked
example: "192.0.2.1"
blacklists:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: List of blacklist check results
listed_count:
type: integer
description: Number of blacklists that have this IP listed
example: 0
score:
type: integer
minimum: 0
maximum: 100
description: Blacklist score (0-100, higher is better)
example: 100
grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade representation of the score
example: "A+"
whitelists:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: List of DNS whitelist check results (informational only)

View file

@ -33,8 +33,8 @@ import (
)
func main() {
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
fmt.Printf("Version: %s\n", version.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 {
@ -52,6 +52,18 @@ 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.Version)
default:
@ -63,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
@ -26,13 +26,6 @@ services:
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

@ -4,7 +4,7 @@ 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"
@ -15,6 +15,10 @@ 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 /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
chown happydeliver:happydeliver /var/log/happydeliver
@ -25,6 +29,15 @@ echo "Configuring Postfix..."
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
# 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

View file

@ -28,7 +28,7 @@ transport_maps = pcre:/etc/postfix/transport_maps
# OpenDKIM for DKIM verification
milter_default_action = accept
milter_protocol = 6
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.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

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

@ -33,6 +33,16 @@ stderr_logfile=/var/log/happydeliver/authentication_milter.log
user=mail
group=mail
# 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/rspamd.log
stderr_logfile=/var/log/happydeliver/rspamd_error.log
user=root
# SpamAssassin daemon
[program:spamd]
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid

64
go.mod
View file

@ -1,41 +1,41 @@
module git.happydns.org/happyDeliver
go 1.24.6
go 1.25.0
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.133.0
github.com/gin-gonic/gin v1.11.0
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.1.2
golang.org/x/net v0.46.0
github.com/oapi-codegen/runtime v1.3.0
golang.org/x/net v0.52.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/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getkin/kin-openapi v0.133.0 // 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/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // 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
@ -43,35 +43,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/redis/go-redis/v9 v9.7.3 // 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
github.com/woodsbury/decimal128 v1.3.0 // 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.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.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
)

132
go.sum
View file

@ -1,17 +1,15 @@
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/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/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=
@ -34,33 +32,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/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/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-openapi/jsonpointer v0.22.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.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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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=
@ -86,8 +86,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=
@ -98,7 +98,6 @@ 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=
@ -110,12 +109,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=
@ -126,10 +125,10 @@ 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/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/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.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@ -152,12 +151,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/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
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=
@ -166,40 +165,43 @@ 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.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
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.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -207,13 +209,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -229,21 +231,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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -256,8 +258,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=
@ -279,5 +281,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

@ -40,6 +40,8 @@ import (
// This interface breaks the circular dependency with pkg/analyzer
type EmailAnalyzer interface {
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
}
// APIHandler implements the ServerInterface for handling API requests
@ -290,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)
}

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

@ -41,6 +41,7 @@ func declareFlags(o *Config) {
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

@ -44,6 +44,7 @@ type Config struct {
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
@ -64,6 +65,7 @@ type AnalysisConfig struct {
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
}
@ -87,6 +89,7 @@ func DefaultConfig() *Config {
DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second,
RBLs: []string{},
DNSWLs: []string{},
CheckAllIPs: false, // By default, only check the first IP
},
}

View file

@ -147,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
}

View file

@ -44,6 +44,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
)
@ -108,3 +109,40 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
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

@ -50,13 +50,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
results.Spf = a.parseLegacySPF(email)
}
if results.Dkim == nil || len(*results.Dkim) == 0 {
dkimResults := a.parseLegacyDKIM(email)
if len(dkimResults) > 0 {
results.Dkim = &dkimResults
}
}
// Parse ARC headers if not already parsed from Authentication-Results
if results.Arc == nil {
results.Arc = a.parseARCHeaders(email)

View file

@ -59,40 +59,6 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
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
}
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
// Expect at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 {

View file

@ -22,7 +22,6 @@
package analyzer
import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
@ -85,246 +84,3 @@ func TestParseDKIMResult(t *testing.T) {
})
}
}
func TestParseLegacyDKIM(t *testing.T) {
tests := []struct {
name string
dkimSignatures []string
expectedCount int
expectedDomains []string
expectedSelector []string
}{
{
name: "Single DKIM signature with domain and selector",
dkimSignatures: []string{
"v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc",
},
expectedCount: 1,
expectedDomains: []string{"example.com"},
expectedSelector: []string{"selector1"},
},
{
name: "Multiple DKIM signatures",
dkimSignatures: []string{
"v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123",
"v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456",
},
expectedCount: 2,
expectedDomains: []string{"example.com", "example.com"},
expectedSelector: []string{"selector1", "selector2"},
},
{
name: "DKIM signature with different domain",
dkimSignatures: []string{
"v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789",
},
expectedCount: 1,
expectedDomains: []string{"mail.example.org"},
expectedSelector: []string{"default"},
},
{
name: "DKIM signature with subdomain",
dkimSignatures: []string{
"v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa",
},
expectedCount: 1,
expectedDomains: []string{"newsletters.example.com"},
expectedSelector: []string{"marketing"},
},
{
name: "Multiple signatures from different domains",
dkimSignatures: []string{
"v=1; a=rsa-sha256; d=example.com; s=s1; b=abc",
"v=1; a=rsa-sha256; d=relay.com; s=s2; b=def",
},
expectedCount: 2,
expectedDomains: []string{"example.com", "relay.com"},
expectedSelector: []string{"s1", "s2"},
},
{
name: "No DKIM signatures",
dkimSignatures: []string{},
expectedCount: 0,
expectedDomains: []string{},
expectedSelector: []string{},
},
{
name: "DKIM signature without selector",
dkimSignatures: []string{
"v=1; a=rsa-sha256; d=example.com; b=abc123",
},
expectedCount: 1,
expectedDomains: []string{"example.com"},
expectedSelector: []string{""},
},
{
name: "DKIM signature without domain",
dkimSignatures: []string{
"v=1; a=rsa-sha256; s=selector1; b=abc123",
},
expectedCount: 1,
expectedDomains: []string{""},
expectedSelector: []string{"selector1"},
},
{
name: "DKIM signature with whitespace in parameters",
dkimSignatures: []string{
"v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123",
},
expectedCount: 1,
expectedDomains: []string{"example.com"},
expectedSelector: []string{"selector1"},
},
{
name: "DKIM signature with multiline format",
dkimSignatures: []string{
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789",
},
expectedCount: 1,
expectedDomains: []string{"example.com"},
expectedSelector: []string{"selector1"},
},
{
name: "DKIM signature with ed25519 algorithm",
dkimSignatures: []string{
"v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz",
},
expectedCount: 1,
expectedDomains: []string{"example.com"},
expectedSelector: []string{"ed25519"},
},
{
name: "Complex real-world DKIM signature",
dkimSignatures: []string{
"v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==",
},
expectedCount: 1,
expectedDomains: []string{"google.com"},
expectedSelector: []string{"20230601"},
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock email message with DKIM-Signature headers
email := &EmailMessage{
Header: make(map[string][]string),
}
if len(tt.dkimSignatures) > 0 {
email.Header["Dkim-Signature"] = tt.dkimSignatures
}
results := analyzer.parseLegacyDKIM(email)
// Check count
if len(results) != tt.expectedCount {
t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results))
return
}
// Check each result
for i, result := range results {
// All legacy DKIM results should have Result = none
if result.Result != api.AuthResultResultNone {
t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone)
}
// Check domain
if i < len(tt.expectedDomains) {
expectedDomain := tt.expectedDomains[i]
if expectedDomain != "" {
if result.Domain == nil {
t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain)
} else if strings.TrimSpace(*result.Domain) != expectedDomain {
t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain)
}
}
}
// Check selector
if i < len(tt.expectedSelector) {
expectedSelector := tt.expectedSelector[i]
if expectedSelector != "" {
if result.Selector == nil {
t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector)
} else if strings.TrimSpace(*result.Selector) != expectedSelector {
t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector)
}
}
}
// Check that Details is set
if result.Details == nil {
t.Errorf("Result[%d].Details = nil, expected non-nil", i)
} else {
expectedDetails := "DKIM signature present (verification status unknown)"
if *result.Details != expectedDetails {
t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails)
}
}
}
})
}
}
func TestParseLegacyDKIM_Integration(t *testing.T) {
hostname = ""
// Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication
t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) {
analyzer := NewAuthenticationAnalyzer()
email := &EmailMessage{
Header: make(map[string][]string),
}
email.Header["Dkim-Signature"] = []string{
"v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc",
}
results := analyzer.AnalyzeAuthentication(email)
if results.Dkim == nil {
t.Fatal("Expected DKIM results, got nil")
}
if len(*results.Dkim) != 1 {
t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim))
}
if (*results.Dkim)[0].Result != api.AuthResultResultNone {
t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result)
}
if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" {
t.Error("Expected domain to be 'example.com'")
}
})
t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) {
analyzer := NewAuthenticationAnalyzer()
email := &EmailMessage{
Header: make(map[string][]string),
}
// Both Authentication-Results and DKIM-Signature headers
email.Header["Authentication-Results"] = []string{
"mx.example.com; dkim=pass header.d=verified.com header.s=s1",
}
email.Header["Dkim-Signature"] = []string{
"v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc",
}
results := analyzer.AnalyzeAuthentication(email)
// Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature
if results.Dkim == nil {
t.Fatal("Expected DKIM results, got nil")
}
if len(*results.Dkim) != 1 {
t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim))
}
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result)
}
if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" {
t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy")
}
})
}

View file

@ -27,6 +27,7 @@ import (
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"time"
"unicode"
@ -37,8 +38,10 @@ import (
// ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct {
Timeout time.Duration
httpClient *http.Client
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
@ -110,6 +113,13 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *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()
@ -331,9 +341,14 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
// isUnsubscribeLink checks if a link is an unsubscribe link
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
// 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"}
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
@ -439,7 +454,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
// Extract the actual destination domain/email based on scheme
var actualDomain string
if parsedURL.Scheme == "mailto" {
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
@ -457,7 +473,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
} else {
return false // Invalid mailto
}
} else if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
case "http":
case "https":
// Check if URL has a host
if parsedURL.Host == "" {
return false
@ -469,7 +486,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
actualDomain = actualDomain[:idx]
}
actualDomain = strings.ToLower(actualDomain)
} else {
default:
// Skip checks for other URL schemes (tel, etc.)
return false
}
@ -492,10 +509,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
"email us", "contact us", "send email", "get in touch", "reach out",
"contact", "email", "write to us",
}
for _, generic := range genericTexts {
if linkText == generic {
return false
}
if slices.Contains(genericTexts, linkText) {
return false
}
// Extract domain-like patterns from link text using regex
@ -562,10 +577,8 @@ func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) boo
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
"buff.ly", "is.gd", "bl.ink", "short.io",
}
for _, shortener := range shorteners {
if strings.ToLower(parsedURL.Host) == shortener {
return true
}
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) {
return true
}
// Check for excessive subdomains (possible obfuscation)
@ -627,7 +640,7 @@ func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
var extract func(*html.Node)
extract = func(n *html.Node) {
if n.Type == html.TextNode {
text.WriteString(n.Data)
text.WriteString(" " + n.Data)
}
// Skip script and style tags
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
@ -639,7 +652,7 @@ func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
}
extract(doc)
return text.String()
return strings.TrimSpace(text.String())
}
// calculateTextPlainConsistency compares plain text and HTML versions
@ -659,30 +672,47 @@ func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText stri
return 0.0
}
// Count common words
commonWords := 0
plainWordSet := make(map[string]bool)
// Count common words by building sets
plainWordSet := make(map[string]int)
for _, word := range plainWords {
plainWordSet[word] = true
plainWordSet[word]++
}
htmlWordSet := make(map[string]int)
for _, word := range htmlWords {
if plainWordSet[word] {
commonWords++
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 (Jaccard similarity approximation)
maxWords := len(plainWords)
if len(htmlWords) > maxWords {
maxWords = len(htmlWords)
}
if maxWords == 0 {
// 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
}
return float32(commonWords) / float32(maxWords)
// 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
@ -707,6 +737,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
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)
@ -853,8 +884,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
// Unsubscribe methods
if results.HasUnsubscribe {
methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
analysis.UnsubscribeMethods = &methods
*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

View file

@ -76,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",
@ -144,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)

View file

@ -22,7 +22,6 @@
package analyzer
import (
"net"
"time"
"git.happydns.org/happyDeliver/internal/api"
@ -31,19 +30,26 @@ import (
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
resolver *net.Resolver
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: &net.Resolver{
PreferGo: true,
},
Timeout: timeout,
resolver: resolver,
}
}
@ -124,6 +130,70 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
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

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)
}

View file

@ -33,11 +33,12 @@ import (
// 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)
return d.resolveSPFRecords(domain, visited, 0, true)
}
// resolveSPFRecords recursively resolves SPF records including include: directives
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord {
// 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 {
@ -103,7 +104,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
}
// Basic validation
validationErr := d.validateSPF(spfRecord)
validationErr := d.validateSPF(spfRecord, isMainRecord)
// Extract the "all" mechanism qualifier
var allQualifier *api.SPFRecordAllQualifier
@ -140,7 +141,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
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)
redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false)
if redirectRecords != nil {
results = append(results, *redirectRecords...)
}
@ -150,7 +151,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
// Extract and resolve include: directives
includes := d.extractSPFIncludes(spfRecord)
for _, includeDomain := range includes {
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1)
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false)
if includedRecords != nil {
results = append(results, *includedRecords...)
}
@ -190,8 +191,12 @@ func (d *DNSAnalyzer) isValidSPFMechanism(token string) error {
// Check if it's a modifier (contains =)
if strings.Contains(mechanism, "=") {
// Only allow known modifiers: redirect= and exp=
if strings.HasPrefix(mechanism, "redirect=") || strings.HasPrefix(mechanism, "exp=") {
// 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
}
@ -236,7 +241,8 @@ func (d *DNSAnalyzer) isValidSPFMechanism(token string) error {
}
// validateSPF performs basic SPF record validation
func (d *DNSAnalyzer) validateSPF(record string) error {
// 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'")
@ -269,19 +275,22 @@ func (d *DNSAnalyzer) validateSPF(record string) error {
return nil
}
// 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
// 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")
if !hasValidEnding {
return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier")
}
}
return nil

View file

@ -122,13 +122,39 @@ func TestValidateSPF(t *testing.T) {
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) {
err := analyzer.validateSPF(tt.record)
// 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)
@ -144,6 +170,74 @@ func TestValidateSPF(t *testing.T) {
}
}
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

View file

@ -52,13 +52,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade := 6
headers := *analysis.Headers
// RP and From alignment (20 points)
if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned {
score += 20
} else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned {
score += 15
} else {
// 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
@ -79,7 +80,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade = 1
}
// Check recommended headers (20 points)
// Check recommended headers (15 points)
recommendedHeaders := []string{"subject", "to"}
// Add reply-to when from is a no-reply address
@ -95,7 +96,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
presentRecommended++
}
}
score += presentRecommended * 20 / recommendedCount
score += presentRecommended * 15 / recommendedCount
if presentRecommended < recommendedCount {
maxGrade -= 1
@ -108,6 +109,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
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
@ -235,7 +243,7 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
}
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis {
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
if email == nil {
return nil
}
@ -265,6 +273,10 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header
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 {
@ -281,7 +293,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header
}
// Domain alignment
domainAlignment := h.analyzeDomainAlignment(email)
domainAlignment := h.analyzeDomainAlignment(email, authResults)
if domainAlignment != nil {
analysis.DomainAlignment = domainAlignment
}
@ -319,12 +331,21 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
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 {
@ -352,8 +373,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
return check
}
// analyzeDomainAlignment checks domain alignment between headers
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
// 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),
@ -383,14 +404,45 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
}
}
// 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{}
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
// 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)
strictAligned := strings.EqualFold(fromDomain, rpDomain)
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
// Relaxed alignment: organizational domain match
var fromOrgDomain, rpOrgDomain string
@ -400,20 +452,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
if alignment.ReturnPathOrgDomain != nil {
rpOrgDomain = *alignment.ReturnPathOrgDomain
}
relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain)
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
*alignment.Aligned = strictAligned
*alignment.RelaxedAligned = relaxedAligned
if !strictAligned {
if relaxedAligned {
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
}

View file

@ -24,6 +24,7 @@ package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
@ -82,8 +83,8 @@ func TestCalculateHeaderScore(t *testing.T) {
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 40,
maxScore: 80,
minScore: 80,
maxScore: 90,
},
{
name: "Invalid Message-ID format",
@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate header analysis first
analysis := analyzer.GenerateHeaderAnalysis(tt.email)
analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil)
score, _ := analyzer.CalculateHeaderScore(analysis)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
@ -360,7 +361,7 @@ func TestAnalyzeDomainAlignment(t *testing.T) {
}),
}
alignment := analyzer.analyzeDomainAlignment(email)
alignment := analyzer.analyzeDomainAlignment(email, nil)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
@ -698,7 +699,7 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
"from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000",
}
analysis := analyzer.GenerateHeaderAnalysis(email)
analysis := analyzer.GenerateHeaderAnalysis(email, nil)
if analysis == nil {
t.Fatal("GenerateHeaderAnalysis returned nil")
@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool {
}
return *a == *b
}
func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
tests := []struct {
name string
fromHeader string
returnPath string
dkimDomains []string
expectStrictAligned bool
expectRelaxedAligned bool
expectIssuesContain string
}{
{
name: "DKIM strict alignment with From domain",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "DKIM relaxed alignment only",
fromHeader: "sender@mail.example.com",
returnPath: "",
dkimDomains: []string{"example.com"},
expectStrictAligned: false,
expectRelaxedAligned: true,
expectIssuesContain: "relaxed alignment",
},
{
name: "DKIM no alignment",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"different.com"},
expectStrictAligned: false,
expectRelaxedAligned: false,
expectIssuesContain: "do not align",
},
{
name: "Multiple DKIM signatures - one aligns",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"different.com", "example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "Return-Path misaligned but DKIM aligned",
fromHeader: "sender@example.com",
returnPath: "bounce@different.com",
dkimDomains: []string{"example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "Return-Path",
},
{
name: "Return-Path aligned, no DKIM",
fromHeader: "sender@example.com",
returnPath: "bounce@example.com",
dkimDomains: []string{},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "Both Return-Path and DKIM misaligned",
fromHeader: "sender@example.com",
returnPath: "bounce@other.com",
dkimDomains: []string{"different.com"},
expectStrictAligned: false,
expectRelaxedAligned: false,
expectIssuesContain: "do not",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": tt.fromHeader,
"Return-Path": tt.returnPath,
}),
}
// Create authentication results with DKIM signatures
var authResults *api.AuthenticationResults
if len(tt.dkimDomains) > 0 {
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
for _, domain := range tt.dkimDomains {
dkimResults = append(dkimResults, api.AuthResult{
Result: api.AuthResultResultPass,
Domain: &domain,
})
}
authResults = &api.AuthenticationResults{
Dkim: &dkimResults,
}
}
alignment := analyzer.analyzeDomainAlignment(email, authResults)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
}
if alignment.Aligned == nil {
t.Fatal("Expected non-nil Aligned field")
}
if *alignment.Aligned != tt.expectStrictAligned {
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned)
}
if alignment.RelaxedAligned == nil {
t.Fatal("Expected non-nil RelaxedAligned field")
}
if *alignment.RelaxedAligned != tt.expectRelaxedAligned {
t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned)
}
// Check DKIM domains are populated
if len(tt.dkimDomains) > 0 {
if alignment.DkimDomains == nil {
t.Error("Expected DkimDomains to be populated")
} else if len(*alignment.DkimDomains) != len(tt.dkimDomains) {
t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains))
}
}
// Check issues contain expected string
if tt.expectIssuesContain != "" {
if alignment.Issues == nil || len(*alignment.Issues) == 0 {
t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain)
} else {
found := false
for _, issue := range *alignment.Issues {
if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) {
found = true
break
}
}
if !found {
t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues)
}
}
}
})
}
}

View file

@ -256,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
}
@ -301,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

View file

@ -27,17 +27,21 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// RBLChecker checks IP addresses against DNS-based blacklists
type RBLChecker struct {
Timeout time.Duration
RBLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
resolver *net.Resolver
// 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
@ -48,40 +52,83 @@ var DefaultRBLs = []string{
"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) *RBLChecker {
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
timeout = 5 * time.Second // Default timeout
timeout = 5 * time.Second
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
return &RBLChecker{
Timeout: timeout,
RBLs: rbls,
CheckAllIPs: checkAllIPs,
resolver: &net.Resolver{
PreferGo: true,
},
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,
}
}
// RBLResults represents the results of RBL checks
type RBLResults struct {
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
IPsChecked []string
ListedCount int
// 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),
}
}
// CheckEmail checks all IPs found in the email headers against RBLs
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results := &RBLResults{
// 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),
}
// Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
@ -89,17 +136,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results.IPsChecked = ips
// Check each IP against all RBLs
for _, ip := range ips {
for _, rbl := range r.RBLs {
check := r.checkIP(ip, rbl)
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++
}
}
}
// Only check the first IP unless CheckAllIPs is enabled
if !r.CheckAllIPs {
break
}
@ -108,28 +156,48 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
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 *RBLChecker) extractIPs(email *EmailMessage) []string {
func (r *DNSListChecker) 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
@ -137,13 +205,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
}
}
// If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
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) {
@ -156,19 +221,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *RBLChecker) isPublicIP(ipStr string) bool {
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
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
}
@ -176,51 +238,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
return true
}
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
// checkIP checks a single IP against a single DNS list
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
check := api.BlacklistCheck{
Rbl: rbl,
Rbl: list,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = api.PtrTo("Failed to reverse IP address")
return check
}
// Construct DNSBL query: reversed-ip.rbl-domain
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
query := fmt.Sprintf("%s.%s", reversedIP, list)
// 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 = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
return check
}
// If we got a response, check the return code
if len(addrs) > 0 {
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
check.Response = api.PtrTo(addrs[0])
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
// These indicate RBL operational issues, not actual listings
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
// 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)", rbl, addrs[0]))
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
} else {
// Normal listing response
check.Listed = true
}
}
@ -228,44 +282,47 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
return check
}
// reverseIP reverses an IPv4 address for DNSBL queries
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
// Example: 192.0.2.1 -> 1.2.0.192
func (r *RBLChecker) reverseIP(ipStr string) string {
func (r *DNSListChecker) 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])
}
// CalculateRBLScore calculates the blacklist contribution to deliverability
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) {
// 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 {
// No IPs to check, give benefit of doubt
return 100, ""
}
percentage := 100 - results.ListedCount*100/len(r.RBLs)
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 RBL
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
// 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, rblChecks := range results.Checks {
for _, check := range rblChecks {
for ip, checks := range results.Checks {
for _, check := range checks {
if check.Listed {
listedIPs = append(listedIPs, ip)
break // Only add the IP once
break
}
}
}
@ -273,17 +330,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
return listedIPs
}
// GetRBLsForIP returns all RBLs that list a specific IP
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
var rbls []string
// GetListsForIP returns all lists that match a specific IP
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
var lists []string
if rblChecks, exists := results.Checks[ip]; exists {
for _, check := range rblChecks {
if checks, exists := results.Checks[ip]; exists {
for _, check := range checks {
if check.Listed {
rbls = append(rbls, check.Rbl)
lists = append(lists, check.Rbl)
}
}
}
return rbls
return lists
}

View file

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

View file

@ -33,8 +33,10 @@ import (
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *RBLChecker
rblChecker *DNSListChecker
dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer
}
@ -44,13 +46,16 @@ 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(),
}
@ -63,8 +68,10 @@ type AnalysisResults struct {
Content *ContentResults
DNS *api.DNSResults
Headers *api.HeaderAnalysis
RBL *RBLResults
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
}
// AnalyzeEmail performs complete email analysis
@ -75,10 +82,12 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(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
@ -131,13 +140,29 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
blacklistScore := 0
var blacklistGrade string
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
}
spamScore := 0
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
if results.SpamAssassin != nil {
spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
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{
@ -177,9 +202,27 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
report.Blacklists = &results.RBL.Checks
}
// Add SpamAssassin result
// 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

View file

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

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)
}
}

View file

@ -45,7 +45,55 @@ func ScoreToGrade(score int) string {
}
}
// 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

@ -50,7 +50,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
}
// Parse X-Spam-Status header
if statusHeader, ok := headers["X-Spam-Status"]; ok {
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
a.parseSpamStatus(statusHeader, result)
}

2550
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,24 +16,24 @@
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.86.4",
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@hey-api/openapi-ts": "0.86.10",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^22",
"eslint": "^9.38.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^24.0.0",
"eslint": "^10.0.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",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.10",
"vite": "^8.0.0",
"vitest": "^3.2.4"
},
"dependencies": {

View file

@ -27,7 +27,6 @@ import (
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
@ -63,6 +62,14 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
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 {
@ -82,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))
@ -130,7 +143,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
}
}
v, _ := ioutil.ReadAll(resp.Body)
v, _ := io.ReadAll(resp.Body)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
@ -157,7 +170,7 @@ 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(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)

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 {

View file

@ -16,10 +16,17 @@
function getAuthResultClass(result: string, noneIsFail: boolean): string {
switch (result) {
case "pass":
case "domain_pass":
case "orgdomain_pass":
return "text-success";
case "permerror":
case "error":
case "fail":
case "missing":
case "invalid":
case "null":
case "null_smtp":
case "null_header":
return "text-danger";
case "softfail":
case "neutral":
@ -36,12 +43,19 @@
function getAuthResultIcon(result: string, noneIsFail: boolean): string {
switch (result) {
case "pass":
case "domain_pass":
case "orgdomain_pass":
return "bi-check-circle-fill";
case "fail":
return "bi-x-circle-fill";
case "softfail":
case "neutral":
case "invalid":
case "null":
case "permerror":
case "error":
case "null_smtp":
case "null_header":
return "bi-exclamation-circle-fill";
case "missing":
return "bi-dash-circle-fill";
@ -84,281 +98,442 @@
</h4>
</div>
<div class="list-group list-group-flush">
<!-- IPREV -->
{#if authentication.iprev}
<div class="list-group-item" id="authentication-iprev">
<div class="d-flex align-items-start">
<i class="bi {getAuthResultIcon(authentication.iprev.result, true)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"></i>
<div>
<strong>IP Reverse DNS</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.iprev.result, true)}">
{authentication.iprev.result}
</span>
{#if authentication.iprev.ip}
<div class="small">
<strong>IP Address:</strong>
<span class="text-muted">{authentication.iprev.ip}</span>
</div>
{/if}
{#if authentication.iprev.hostname}
<div class="small">
<strong>Hostname:</strong>
<span class="text-muted">{authentication.iprev.hostname}</span>
</div>
{/if}
{#if authentication.iprev.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.iprev.details}</pre>
{/if}
</div>
<!-- IPREV -->
{#if authentication.iprev}
<div class="list-group-item" id="authentication-iprev">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.iprev.result,
true,
)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"
></i>
<div>
<strong>IP Reverse DNS</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.iprev.result,
true,
)}"
>
{authentication.iprev.result}
</span>
{#if authentication.iprev.ip}
<div class="small">
<strong>IP Address:</strong>
<span class="text-muted">{authentication.iprev.ip}</span>
</div>
{/if}
{#if authentication.iprev.hostname}
<div class="small">
<strong>Hostname:</strong>
<span class="text-muted">{authentication.iprev.hostname}</span>
</div>
{/if}
{#if authentication.iprev.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.iprev.details}</pre>
{/if}
</div>
</div>
{/if}
<!-- SPF (Required) -->
<div class="list-group-item">
<div class="d-flex align-items-start" id="authentication-spf">
{#if authentication.spf}
<i class="bi {getAuthResultIcon(authentication.spf.result, true)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"></i>
<div>
<strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.spf.result, true)}">
{authentication.spf.result}
</span>
{#if authentication.spf.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.spf.domain}</span>
</div>
{/if}
{#if authentication.spf.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.spf.details}</pre>
{/if}
</div>
{:else}
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
<div>
<strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText('missing')}
</span>
<div class="text-muted small">SPF record is required for proper email authentication</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- DKIM (Required) -->
<div class="list-group-item" id="authentication-dkim">
{#if authentication.dkim && authentication.dkim.length > 0}
{#each authentication.dkim as dkim, i}
<div class="d-flex align-items-start" class:mt-3={i > 0}>
<i class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(dkim.result, true)} me-2 fs-5"></i>
<div>
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ''}</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}">
{dkim.result}
</span>
{#if dkim.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{dkim.domain}</span>
</div>
{/if}
{#if dkim.selector}
<div class="small">
<strong>Selector:</strong>
<span class="text-muted">{dkim.selector}</span>
</div>
{/if}
{#if dkim.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{dkim.details}</pre>
{/if}
<!-- SPF (Required) -->
<div class="list-group-item">
<div class="d-flex align-items-start" id="authentication-spf">
{#if authentication.spf}
<i
class="bi {getAuthResultIcon(
authentication.spf.result,
true,
)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"
></i>
<div>
<strong>SPF</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.spf.result,
true,
)}"
>
{authentication.spf.result}
</span>
{#if authentication.spf.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.spf.domain}</span>
</div>
</div>
{/each}
{/if}
{#if authentication.spf.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.spf.details}</pre>
{/if}
</div>
{:else}
<div class="d-flex align-items-start">
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
<div>
<strong>DKIM</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText('missing')}
</span>
<div class="text-muted small">DKIM signature is required for proper email authentication</div>
<i
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
'missing',
true,
)} me-2 fs-5"
></i>
<div>
<strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText("missing")}
</span>
<div class="text-muted small">
SPF record is required for proper email authentication
</div>
</div>
{/if}
</div>
</div>
<!-- X-Google-DKIM (Optional) -->
{#if authentication.x_google_dkim}
<div class="list-group-item" id="authentication-x-google-dkim">
<div class="d-flex align-items-start">
<i class="bi {getAuthResultIcon(authentication.x_google_dkim.result, false)} {getAuthResultClass(authentication.x_google_dkim.result, false)} me-2 fs-5"></i>
<!-- DKIM (Required) -->
<div class="list-group-item" id="authentication-dkim">
{#if authentication.dkim && authentication.dkim.length > 0}
{#each authentication.dkim as dkim, i}
<div class="d-flex align-items-start" class:mt-3={i > 0}>
<i
class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(
dkim.result,
true,
)} me-2 fs-5"
></i>
<div>
<strong>X-Google-DKIM</strong>
<i class="bi bi-info-circle text-muted ms-1" title="Google's internal DKIM signature for messages routed through Gmail infrastructure"></i>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_google_dkim.result, false)}">
{authentication.x_google_dkim.result}
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""}</strong
>
<span
class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}"
>
{dkim.result}
</span>
{#if authentication.x_google_dkim.domain}
{#if dkim.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.x_google_dkim.domain}</span>
<span class="text-muted">{dkim.domain}</span>
</div>
{/if}
{#if authentication.x_google_dkim.selector}
{#if dkim.selector}
<div class="small">
<strong>Selector:</strong>
<span class="text-muted">{authentication.x_google_dkim.selector}</span>
<span class="text-muted">{dkim.selector}</span>
</div>
{/if}
{#if authentication.x_google_dkim.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.x_google_dkim.details}</pre>
{#if dkim.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{dkim.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- X-Aligned-From (Disabled) -->
{#if authentication.x_aligned_from}
<div class="list-group-item" id="authentication-x-aligned-from">
<div class="d-flex align-items-start">
<i class="bi {getAuthResultIcon(authentication.x_aligned_from.result, false)} {getAuthResultClass(authentication.x_aligned_from.result, false)} me-2 fs-5"></i>
<div>
<strong>X-Aligned-From</strong>
<i class="bi bi-info-circle text-muted ms-1" title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."></i>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_aligned_from.result, false)}">
{authentication.x_aligned_from.result}
</span>
{#if authentication.x_aligned_from.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.x_aligned_from.domain}</span>
</div>
{/if}
{#if authentication.x_aligned_from.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.x_aligned_from.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- DMARC (Required) -->
<div class="list-group-item" id="authentication-dmarc">
{/each}
{:else}
<div class="d-flex align-items-start">
{#if authentication.dmarc}
<i class="bi {getAuthResultIcon(authentication.dmarc.result, true)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"></i>
<div>
<strong>DMARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.dmarc.result, true)}">
{authentication.dmarc.result}
</span>
{#if authentication.dmarc.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.dmarc.domain}</span>
</div>
{/if}
{#snippet DMARCPolicy(policy: string)}
<div class="small">
<strong>Policy:</strong>
<span
class="fw-bold"
class:text-success={policy == "reject"}
class:text-warning={policy == "quarantine"}
class:text-danger={policy == "none"}
class:bg-warning={policy != "none" && policy != "quarantine" && policy != "reject"}
>
{policy}
</span>
</div>
{/snippet}
{#if authentication.dmarc.result != "none"}
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
{@const policy = authentication.dmarc.details.replace(/^.*policy.published-domain-policy=([^\s]+).*$/, "$1")}
{@render DMARCPolicy(policy)}
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
{/if}
{/if}
{#if authentication.dmarc.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
{/if}
<i
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
'missing',
true,
)} me-2 fs-5"
></i>
<div>
<strong>DKIM</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText("missing")}
</span>
<div class="text-muted small">
DKIM signature is required for proper email authentication
</div>
{:else}
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
<div>
<strong>DMARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText('missing')}
</span>
<div class="text-muted small">DMARC policy is required for proper email authentication</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- X-Google-DKIM (Optional) -->
{#if authentication.x_google_dkim}
<div class="list-group-item" id="authentication-x-google-dkim">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.x_google_dkim.result,
false,
)} {getAuthResultClass(
authentication.x_google_dkim.result,
false,
)} me-2 fs-5"
></i>
<div>
<strong>X-Google-DKIM</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Google's internal DKIM signature for messages routed through Gmail infrastructure"
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_google_dkim.result,
false,
)}"
>
{authentication.x_google_dkim.result}
</span>
{#if authentication.x_google_dkim.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.x_google_dkim.domain}</span
>
</div>
{/if}
{#if authentication.x_google_dkim.selector}
<div class="small">
<strong>Selector:</strong>
<span class="text-muted"
>{authentication.x_google_dkim.selector}</span
>
</div>
{/if}
{#if authentication.x_google_dkim.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.x_google_dkim
.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- BIMI (Optional) -->
<div class="list-group-item" id="authentication-bimi">
<!-- X-Aligned-From (Disabled) -->
{#if authentication.x_aligned_from}
<div class="list-group-item" id="authentication-x-aligned-from">
<div class="d-flex align-items-start">
{#if authentication.bimi && authentication.bimi.result != "none"}
<i class="bi {getAuthResultIcon(authentication.bimi.result, false)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.bimi.result, false)}">
{authentication.bimi.result}
</span>
{#if authentication.bimi.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else if authentication.bimi && authentication.bimi.result == "none"}
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-warning">
NONE
</span>
<div class="text-muted small">Brand Indicators for Message Identification</div>
{#if authentication.bimi.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else}
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-muted">
Optional
</span>
<div class="text-muted small">Brand Indicators for Message Identification (optional enhancement)</div>
</div>
{/if}
</div>
</div>
<!-- ARC (Optional) -->
{#if authentication.arc}
<div class="list-group-item" id="authentication-arc">
<div class="d-flex align-items-start">
<i class="bi {getAuthResultIcon(authentication.arc.result, false)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"></i>
<div>
<strong>ARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.arc.result, false)}">
{authentication.arc.result}
</span>
{#if authentication.arc.chain_length}
<div class="text-muted small">Chain length: {authentication.arc.chain_length}</div>
{/if}
{#if authentication.arc.details}
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.arc.details}</pre>
{/if}
</div>
<i
class="bi {getAuthResultIcon(
authentication.x_aligned_from.result,
false,
)} {getAuthResultClass(
authentication.x_aligned_from.result,
false,
)} me-2 fs-5"
></i>
<div>
<strong>X-Aligned-From</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_aligned_from.result,
false,
)}"
>
{authentication.x_aligned_from.result}
</span>
{#if authentication.x_aligned_from.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted"
>{authentication.x_aligned_from.domain}</span
>
</div>
{/if}
{#if authentication.x_aligned_from.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.x_aligned_from
.details}</pre>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<!-- DMARC (Required) -->
<div class="list-group-item" id="authentication-dmarc">
<div class="d-flex align-items-start">
{#if authentication.dmarc}
<i
class="bi {getAuthResultIcon(
authentication.dmarc.result,
true,
)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"
></i>
<div>
<strong>DMARC</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.dmarc.result,
true,
)}"
>
{authentication.dmarc.result}
</span>
{#if authentication.dmarc.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.dmarc.domain}</span>
</div>
{/if}
{#snippet DMARCPolicy(policy: string)}
<div class="small">
<strong>Policy:</strong>
<span
class="fw-bold"
class:text-success={policy == "reject"}
class:text-warning={policy == "quarantine"}
class:text-danger={policy == "none"}
class:bg-warning={policy != "none" &&
policy != "quarantine" &&
policy != "reject"}
>
{policy}
</span>
</div>
{/snippet}
{#if authentication.dmarc.result != "none"}
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
{@const policy = authentication.dmarc.details.replace(
/^.*policy.published-domain-policy=([^\s]+).*$/,
"$1",
)}
{@render DMARCPolicy(policy)}
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
{/if}
{/if}
{#if authentication.dmarc.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
{/if}
</div>
{:else}
<i
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
'missing',
true,
)} me-2 fs-5"
></i>
<div>
<strong>DMARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText("missing")}
</span>
<div class="text-muted small">
DMARC policy is required for proper email authentication
</div>
</div>
{/if}
</div>
</div>
<!-- BIMI (Optional) -->
<div class="list-group-item" id="authentication-bimi">
<div class="d-flex align-items-start">
{#if authentication.bimi && authentication.bimi.result != "none"}
<i
class="bi {getAuthResultIcon(
authentication.bimi.result,
false,
)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"
></i>
<div>
<strong>BIMI</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.bimi.result,
false,
)}"
>
{authentication.bimi.result}
</span>
{#if authentication.bimi.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else if authentication.bimi && authentication.bimi.result == "none"}
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-warning"> NONE </span>
<div class="text-muted small">
Brand Indicators for Message Identification
</div>
{#if authentication.bimi.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else}
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-muted"> Optional </span>
<div class="text-muted small">
Brand Indicators for Message Identification (optional enhancement)
</div>
</div>
{/if}
</div>
</div>
<!-- ARC (Optional) -->
{#if authentication.arc}
<div class="list-group-item" id="authentication-arc">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.arc.result,
false,
)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"
></i>
<div>
<strong>ARC</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.arc.result,
false,
)}"
>
{authentication.arc.result}
</span>
{#if authentication.arc.chain_length}
<div class="text-muted small">
Chain length: {authentication.arc.chain_length}
</div>
{/if}
{#if authentication.arc.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.arc.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>

View file

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

View file

@ -36,16 +36,28 @@
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bi {contentAnalysis.has_html ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
<i
class="bi {contentAnalysis.has_html
? 'bi-check-circle text-success'
: 'bi-x-circle text-muted'} me-2"
></i>
<span>HTML Part</span>
</div>
<div class="d-flex align-items-center mb-2">
<i class="bi {contentAnalysis.has_plaintext ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
<i
class="bi {contentAnalysis.has_plaintext
? 'bi-check-circle text-success'
: 'bi-x-circle text-muted'} me-2"
></i>
<span>Plaintext Part</span>
</div>
{#if typeof contentAnalysis.has_unsubscribe_link === 'boolean'}
{#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
<div class="d-flex align-items-center mb-2">
<i class="bi {contentAnalysis.has_unsubscribe_link ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'} me-2"></i>
<i
class="bi {contentAnalysis.has_unsubscribe_link
? 'bi-check-circle text-success'
: 'bi-x-circle text-warning'} me-2"
></i>
<span>Unsubscribe Link</span>
</div>
{/if}
@ -74,7 +86,14 @@
<div class="mt-3">
<h5>Content Issues</h5>
{#each contentAnalysis.html_issues as issue}
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
<div
class="alert alert-{issue.severity === 'critical' ||
issue.severity === 'high'
? 'danger'
: issue.severity === 'medium'
? 'warning'
: 'info'} py-2 px-3 mb-2"
>
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>{issue.type}</strong>
@ -118,11 +137,17 @@
{/if}
</td>
<td>
<span class="badge {link.status === 'valid' ? 'bg-success' : link.status === 'broken' ? 'bg-danger' : 'bg-warning'}">
<span
class="badge {link.status === 'valid'
? 'bg-success'
: link.status === 'broken'
? 'bg-danger'
: 'bg-warning'}"
>
{link.status}
</span>
</td>
<td>{link.http_code || '-'}</td>
<td>{link.http_code || "-"}</td>
</tr>
{/each}
</tbody>
@ -146,11 +171,11 @@
<tbody>
{#each contentAnalysis.images as image}
<tr>
<td><small class="text-break">{image.src || '-'}</small></td>
<td><small class="text-break">{image.src || "-"}</small></td>
<td>
{#if image.has_alt}
<i class="bi bi-check-circle text-success me-1"></i>
<small>{image.alt_text || 'Present'}</small>
<small>{image.alt_text || "Present"}</small>
{:else}
<i class="bi bi-x-circle text-warning me-1"></i>
<small class="text-muted">Missing</small>

View file

@ -34,9 +34,10 @@
</div>
<div class="card-body">
<p class="card-text small text-muted mb-2">
DMARC builds on SPF and DKIM by telling receiving servers what to do with emails
that fail authentication checks. It also enables reporting so you can monitor your
email security.
DMARC enforces domain alignment requirements (regardless of the policy). It builds
on SPF and DKIM by telling receiving servers what to do with emails that fail
authentication checks. It also enables reporting so you can monitor your email
security.
</p>
<hr />

View file

@ -1,15 +1,15 @@
<script lang="ts">
import type { DomainAlignment, DnsResults, ReceivedHop } from "$lib/api/types.gen";
import type { DnsResults, DomainAlignment, ReceivedHop } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import GradeDisplay from "./GradeDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
interface Props {
domainAlignment?: DomainAlignment;
@ -17,9 +17,17 @@
dnsGrade?: string;
dnsScore?: number;
receivedChain?: ReceivedHop[];
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
}
let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain }: Props = $props();
let {
domainAlignment,
dnsResults,
dnsGrade,
dnsScore,
receivedChain,
domainOnly = false,
}: Props = $props();
// Extract sender IP from first hop
const senderIp = $derived(
@ -61,69 +69,85 @@
</div>
{/if}
<!-- Reverse IP Section -->
{#if receivedChain && receivedChain.length > 0}
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate">
Received from: <code>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])</code>
</h4>
</div>
{/if}
{#if !domainOnly}
<!-- Reverse IP Section -->
{#if receivedChain && receivedChain.length > 0}
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate">
Received from: <code
>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0]
.ip}])</code
>
</h4>
</div>
{/if}
<!-- PTR Records Section -->
<PtrRecordsDisplay ptrRecords={dnsResults.ptr_records} {senderIp} />
<!-- PTR Records Section -->
<PtrRecordsDisplay ptrRecords={dnsResults.ptr_records} {senderIp} />
<!-- Forward-Confirmed Reverse DNS -->
<PtrForwardRecordsDisplay
ptrRecords={dnsResults.ptr_records}
ptrForwardRecords={dnsResults.ptr_forward_records}
{senderIp}
/>
<hr class="my-4" />
<!-- Return-Path Domain Section -->
<div class="mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<h4 class="mb-0 text-truncate">
Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
</h4>
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain</span>
<small>
<i class="bi bi-chevron-right"></i>
<a href="#domain-alignment">See domain alignment</a>
</small>
{:else}
<span class="badge bg-success ms-2">Same as From domain</span>
{/if}
</div>
</div>
<!-- MX Records for Return-Path Domain -->
{#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0}
<MxRecordsDisplay
class="mb-4"
mxRecords={dnsResults.rp_mx_records}
title="Mail Exchange Records for Return-Path Domain"
description="These MX records handle bounce messages and non-delivery reports."
<!-- Forward-Confirmed Reverse DNS -->
<PtrForwardRecordsDisplay
ptrRecords={dnsResults.ptr_records}
ptrForwardRecords={dnsResults.ptr_forward_records}
{senderIp}
/>
<hr class="my-4" />
<!-- Return-Path Domain Section -->
<div class="mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<h4 class="mb-0 text-truncate">
Return-Path Domain:
<code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
</h4>
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
<span class="badge bg-danger ms-2">
<i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain
</span>
<small>
<i class="bi bi-chevron-right"></i>
<a href="#domain-alignment">See domain alignment</a>
</small>
{:else}
<span class="badge bg-success ms-2">Same as From domain</span>
{/if}
</div>
</div>
<!-- MX Records for Return-Path Domain -->
{#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0}
<MxRecordsDisplay
class="mb-4"
mxRecords={dnsResults.rp_mx_records}
title="Mail Exchange Records for Return-Path Domain"
description="These MX records handle bounce messages and non-delivery reports."
/>
{/if}
{/if}
<!-- SPF Records (for Return-Path Domain) -->
<SpfRecordsDisplay spfRecords={dnsResults.spf_records} dmarcRecord={dnsResults.dmarc_record} />
<SpfRecordsDisplay
spfRecords={dnsResults.spf_records}
dmarcRecord={dnsResults.dmarc_record}
/>
<hr class="my-4">
{#if !domainOnly}
<hr class="my-4" />
<!-- From Domain Section -->
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate">
From Domain: <code>{dnsResults.from_domain}</code>
</h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain</span>
{/if}
</div>
<!-- From Domain Section -->
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate">
From Domain: <code>{dnsResults.from_domain}</code>
</h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2">
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
domain
</span>
{/if}
</div>
{/if}
<!-- MX Records for From Domain -->
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
@ -135,8 +159,10 @@
/>
{/if}
<!-- DKIM Records -->
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
{#if !domainOnly}
<!-- DKIM Records -->
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
{/if}
<!-- DMARC Record -->
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { ReceivedHop } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
receivedChain: ReceivedHop[];
@ -9,23 +10,42 @@
</script>
{#if receivedChain && receivedChain.length > 0}
<div class="mb-3" id="email-path">
<h5>Email Path (Received Chain)</h5>
<div class="list-group">
<div class="card shadow-sm" id="email-path">
<div
class="card-header"
class:bg-white={$theme === "light"}
class:bg-dark={$theme !== "light"}
>
<h4 class="mb-0">
<i class="bi bi-pin-map me-2"></i>
Email Path
</h4>
</div>
<div class="list-group list-group-flush">
{#each receivedChain as hop, i}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
{hop.reverse || '-'} {#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if}{hop.by || 'Unknown'}
{hop.reverse || "-"}
{#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if}{hop.by ||
"Unknown"}
</h6>
<small class="text-muted" title={hop.timestamp}>{hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'}</small>
<small class="text-muted" title={hop.timestamp}>
{hop.timestamp
? new Intl.DateTimeFormat("default", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(hop.timestamp))
: "-"}
</small>
</div>
{#if hop.with || hop.id}
{#if hop.with || hop.id || hop.from}
<p class="mb-1 small d-flex gap-3">
{#if hop.with}
<span>
<span class="text-muted">Protocol:</span> <code>{hop.with}</code>
<span class="text-muted">Protocol:</span>
<code>{hop.with}</code>
</span>
{/if}
{#if hop.id}

View file

@ -44,10 +44,7 @@
}
</script>
<strong
class={getSizeClass(size)}
style="color: {getGradeColor(grade)}; font-weight: 700;"
>
<strong class={getSizeClass(size)} style="color: {getGradeColor(grade)}; font-weight: 700;">
{#if grade}
{grade}
{:else}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { AuthResult, DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
import type { DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
@ -9,7 +9,6 @@
headerAnalysis: HeaderAnalysis;
headerGrade?: string;
headerScore?: number;
xAlignedFrom?: AuthResult;
}
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
@ -39,7 +38,14 @@
<div class="mb-3">
<h5>Issues</h5>
{#each headerAnalysis.issues as issue}
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
<div
class="alert alert-{issue.severity === 'critical' ||
issue.severity === 'high'
? 'danger'
: issue.severity === 'medium'
? 'warning'
: 'info'} py-2 px-3 mb-2"
>
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>{issue.header}</strong>
@ -59,80 +65,282 @@
{/if}
{#if headerAnalysis.domain_alignment}
{@const spfStrictAligned =
headerAnalysis.domain_alignment.from_domain ===
headerAnalysis.domain_alignment.return_path_domain}
{@const spfRelaxedAligned =
headerAnalysis.domain_alignment.from_org_domain ===
headerAnalysis.domain_alignment.return_path_org_domain}
<div class="card mb-3" id="domain-alignment">
<div class="card-header">
<h5 class="mb-0">
{#if xAlignedFrom}
<i class="bi {xAlignedFrom.result == "pass" ? 'bi-check-circle-fill text-success' : 'bi-x-circle-fill text-danger'}"></i>
{:else}
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle text-info' : 'bi-x-circle-fill text-danger'}"></i>
{/if}
<i
class="bi {headerAnalysis.domain_alignment.aligned
? 'bi-check-circle-fill text-success'
: headerAnalysis.domain_alignment.relaxed_aligned
? 'bi-check-circle text-info'
: 'bi-x-circle-fill text-danger'}"
></i>
Domain Alignment
</h5>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-3">
Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers.
<p class="card-text small text-muted">
Domain alignment ensures that the visible "From" domain matches the domain
used for authentication (Return-Path or DKIM signature). Proper alignment is
crucial for DMARC compliance, regardless of the policy. It helps prevent
email spoofing by verifying that the sender domain is consistent across all
authentication layers. Only one of the following lines needs to pass.
</p>
<div class="row">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.relaxed_aligned} class:bg-danger={!headerAnalysis.domain_alignment.relaxed_aligned}>
<i class="bi {headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
{/if}
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
<div
class="alert alert-{headerAnalysis.domain_alignment
.relaxed_aligned
? 'info'
: 'warning'} py-2 small mb-2"
>
<i
class="bi bi-{headerAnalysis.domain_alignment
.relaxed_aligned
? 'info-circle'
: 'exclamation-triangle'} me-1"
></i>
{issue}
</div>
{/each}
</div>
{/if}
</div>
<div class="list-group list-group-flush">
<div class="list-group-item d-flex ps-0">
<div
class="d-flex align-items-center justify-content-center"
style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;"
>
SPF
</div>
<div class="flex-fill">
<div class="row flex-grow-1">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span
class="badge"
class:bg-success={spfStrictAligned}
class:bg-danger={!spfStrictAligned}
>
<i
class="bi {spfStrictAligned
? 'bi-check-circle-fill'
: 'bi-x-circle-fill'} me-1"
></i>
<strong>{spfStrictAligned ? "Pass" : "Fail"}</strong>
</span>
</div>
<div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span
class="badge"
class:bg-success={spfRelaxedAligned}
class:bg-danger={!spfRelaxedAligned}
>
<i
class="bi {spfRelaxedAligned
? 'bi-check-circle-fill'
: 'bi-x-circle-fill'} me-1"
></i>
<strong>{spfRelaxedAligned ? "Pass" : "Fail"}</strong>
</span>
</div>
<div class="small text-muted mt-1">
Organizational domain match
</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div>
<code>
{headerAnalysis.domain_alignment.from_domain || "-"}
</code>
</div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">
Org:
<code>
{headerAnalysis.domain_alignment.from_org_domain}
</code>
</div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Return-Path Domain</small>
<div>
<code>
{headerAnalysis.domain_alignment.return_path_domain ||
"-"}
</code>
</div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">
Org:
<code>
{headerAnalysis.domain_alignment
.return_path_org_domain}
</code>
</div>
{/if}
</div>
</div>
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.spf_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass.
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div
class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment ===
'strict'
? 'alert-warning'
: 'alert-info'}"
>
{#if dmarcRecord.spf_alignment === "strict"}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy
requires exact domain match. The Return-Path domain must exactly
match the From domain for SPF to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy
allows organizational domain matching. As long as both domains
share the same organizational domain (e.g., mail.example.com
and example.com), SPF alignment can pass.
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
{@const dkim_aligned =
dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
{@const dkim_relaxed_aligned =
dkim_domain.org_domain ===
headerAnalysis.domain_alignment.from_org_domain}
<div class="list-group-item d-flex ps-0">
<div
class="d-flex align-items-center justify-content-center"
style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;"
>
DKIM
</div>
<div class="flex-fill">
<div class="flex-fill">
<div class="row flex-grow-1">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span
class="badge"
class:bg-success={dkim_aligned}
class:bg-danger={!dkim_aligned}
>
<i
class="bi {dkim_aligned
? 'bi-check-circle-fill'
: 'bi-x-circle-fill'} me-1"
></i>
<strong>{dkim_aligned ? "Pass" : "Fail"}</strong
>
</span>
</div>
<div class="small text-muted mt-1">
Exact domain match
</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span
class="badge"
class:bg-success={dkim_relaxed_aligned}
class:bg-danger={!dkim_relaxed_aligned}
>
<i
class="bi {dkim_relaxed_aligned
? 'bi-check-circle-fill'
: 'bi-x-circle-fill'} me-1"
></i>
<strong
>{dkim_relaxed_aligned
? "Pass"
: "Fail"}</strong
>
</span>
</div>
<div class="small text-muted mt-1">
Organizational domain match
</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div>
<code
>{headerAnalysis.domain_alignment.from_domain ||
"-"}</code
>
</div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">
Org: <code
>{headerAnalysis.domain_alignment
.from_org_domain}</code
>
</div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Signature Domain</small>
<div><code>{dkim_domain.domain || "-"}</code></div>
{#if dkim_domain.domain !== dkim_domain.org_domain}
<div class="small text-muted mt-1">
Org: <code>{dkim_domain.org_domain}</code>
</div>
{/if}
</div>
</div>
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
<div
class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment ===
'strict'
? 'alert-warning'
: 'alert-info'}"
>
{#if dmarcRecord.dkim_alignment === "strict"}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict DKIM alignment required</strong>
Your DMARC policy requires exact domain match. The
DKIM signature domain must exactly match the From
domain for DKIM to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed DKIM alignment allowed</strong>
Your DMARC policy allows organizational domain matching.
As long as both domains share the same organizational
domain (e.g., mail.example.com and example.com),
DKIM alignment can pass.
{/if}
</div>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
@ -153,9 +361,9 @@
</thead>
<tbody>
{#each Object.entries(headerAnalysis.headers).sort((a, b) => {
const importanceOrder = { 'required': 0, 'recommended': 1, 'optional': 2, 'newsletter': 3 };
const aImportance = importanceOrder[a[1].importance || 'optional'];
const bImportance = importanceOrder[b[1].importance || 'optional'];
const importanceOrder = { required: 0, recommended: 1, optional: 2, newsletter: 3 };
const aImportance = importanceOrder[a[1].importance || "optional"];
const bImportance = importanceOrder[b[1].importance || "optional"];
return aImportance - bImportance;
}) as [name, check]}
<tr>
@ -164,23 +372,39 @@
</td>
<td>
{#if check.importance}
<small class="text-{check.importance === 'required' ? 'danger' : check.importance === 'recommended' ? 'warning' : 'secondary'}">
<small
class="text-{check.importance === 'required'
? 'danger'
: check.importance === 'recommended'
? 'warning'
: 'secondary'}"
>
{check.importance}
</small>
{/if}
</td>
<td>
<i class="bi {check.present ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'}"></i>
<i
class="bi {check.present
? 'bi-check-circle text-success'
: 'bi-x-circle text-danger'}"
></i>
</td>
<td>
{#if check.present && check.valid !== undefined}
<i class="bi {check.valid ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'}"></i>
<i
class="bi {check.valid
? 'bi-check-circle text-success'
: 'bi-x-circle text-warning'}"
></i>
{:else}
-
{/if}
</td>
<td>
<small class="text-muted text-truncate" title={check.value}>{check.value || '-'}</small>
<small class="text-muted text-truncate" title={check.value}
>{check.value || "-"}</small
>
{#if check.issues && check.issues.length > 0}
{#each check.issues as issue}
<div class="text-warning small">

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
import type { MxRecord } from "$lib/api/types.gen";
interface Props {

View file

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

View file

@ -0,0 +1,146 @@
<script lang="ts">
import type { RspamdResult } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
rspamd: RspamdResult;
}
let { rspamd }: Props = $props();
// Derive effective action from score vs known rspamd default thresholds.
// The action header is unreliable in milter setups (always "no action").
const RSPAMD_GREYLIST_THRESHOLD = 4;
const RSPAMD_ADD_HEADER_THRESHOLD = 6;
const effectiveAction = $derived.by(() => {
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" };
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
return { label: "Add header", cls: "bg-warning text-dark" };
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
return { label: "Greylist", cls: "bg-warning text-dark" };
return { label: "No action", cls: "bg-success" };
});
</script>
<div class="card shadow-sm" id="rspamd-details">
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-bug me-2"></i>
rspamd Analysis
</span>
<span>
{#if rspamd.deliverability_score !== undefined}
<span class="badge bg-{getScoreColorClass(rspamd.deliverability_score)}">
{rspamd.deliverability_score}%
</span>
{/if}
{#if rspamd.deliverability_grade !== undefined}
<GradeDisplay grade={rspamd.deliverability_grade} size="small" />
{/if}
</span>
</h4>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<strong>Score:</strong>
<span class={rspamd.is_spam ? "text-danger" : "text-success"}>
{rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)}
</span>
</div>
<div class="col-md-4">
<strong>Classified as:</strong>
<span class="badge {rspamd.is_spam ? 'bg-danger' : 'bg-success'} ms-2">
{rspamd.is_spam ? "SPAM" : "HAM"}
</span>
</div>
<div class="col-md-4">
<strong>Action:</strong>
<span class="badge {effectiveAction.cls} ms-2">
{effectiveAction.label}
</span>
</div>
</div>
{#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0}
<div class="mb-3">
<div class="table-responsive mt-2">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Symbol</th>
<th class="text-end">Score</th>
<th>Parameters</th>
</tr>
</thead>
<tbody>
{#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]}
<tr
class={symbol.score > 0
? "table-warning"
: symbol.score < 0
? "table-success"
: ""}
>
<td class="font-monospace">{symbolName}</td>
<td class="text-end">
<span
class={symbol.score > 0
? "text-danger fw-bold"
: symbol.score < 0
? "text-success fw-bold"
: "text-muted"}
>
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
</span>
</td>
<td class="small text-muted">{symbol.params ?? ""}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if rspamd.report}
<details class="mt-3">
<summary class="cursor-pointer fw-bold">Raw Report</summary>
<pre
class="mt-2 small {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} p-3 rounded">{rspamd.report}</pre>
</details>
{/if}
</div>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
/* Darker table colors in dark mode */
:global([data-bs-theme="dark"]) .table-warning {
--bs-table-bg: rgba(255, 193, 7, 0.2);
--bs-table-border-color: rgba(255, 193, 7, 0.3);
}
:global([data-bs-theme="dark"]) .table-success {
--bs-table-bg: rgba(25, 135, 84, 0.2);
--bs-table-border-color: rgba(25, 135, 84, 0.3);
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type { ScoreSummary } from "$lib/api/types.gen";
import GradeDisplay from "./GradeDisplay.svelte";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
grade: string;
@ -58,13 +58,10 @@
<a href="#dns-details" class="text-decoration-none">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== 'light'}
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
grade={summary.dns_grade}
score={summary.dns_score}
/>
<GradeDisplay grade={summary.dns_grade} score={summary.dns_score} />
<small class="text-muted d-block">DNS</small>
</div>
</a>
@ -73,8 +70,8 @@
<a href="#authentication-details" class="text-decoration-none">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== 'light'}
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
grade={summary.authentication_grade}
@ -88,8 +85,8 @@
<a href="#rbl-details" class="text-decoration-none">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== 'light'}
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
grade={summary.blacklist_grade}
@ -103,8 +100,8 @@
<a href="#header-details" class="text-decoration-none">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== 'light'}
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
grade={summary.header_grade}
@ -118,13 +115,10 @@
<a href="#spam-details" class="text-decoration-none">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== 'light'}
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
grade={summary.spam_grade}
score={summary.spam_score}
/>
<GradeDisplay grade={summary.spam_grade} score={summary.spam_score} />
<small class="text-muted d-block">Spam Score</small>
</div>
</a>
@ -133,8 +127,8 @@
<a href="#content-details" class="text-decoration-none">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== 'light'}
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay
grade={summary.content_grade}

View file

@ -6,11 +6,9 @@
interface Props {
spamassassin: SpamAssassinResult;
spamGrade?: string;
spamScore?: number;
}
let { spamassassin, spamGrade, spamScore }: Props = $props();
let { spamassassin }: Props = $props();
</script>
<div class="card shadow-sm" id="spam-details">
@ -21,13 +19,13 @@
SpamAssassin Analysis
</span>
<span>
{#if spamScore !== undefined}
<span class="badge bg-{getScoreColorClass(spamScore)}">
{spamScore}%
{#if spamassassin.deliverability_score !== undefined}
<span class="badge bg-{getScoreColorClass(spamassassin.deliverability_score)}">
{spamassassin.deliverability_score}%
</span>
{/if}
{#if spamGrade !== undefined}
<GradeDisplay grade={spamGrade} size="small" />
{#if spamassassin.deliverability_grade !== undefined}
<GradeDisplay grade={spamassassin.deliverability_grade} size="small" />
{/if}
</span>
</h4>
@ -61,14 +59,26 @@
</thead>
<tbody>
{#each Object.entries(spamassassin.test_details) as [testName, detail]}
<tr class={detail.score > 0 ? 'table-warning' : detail.score < 0 ? 'table-success' : ''}>
<tr
class={detail.score > 0
? "table-warning"
: detail.score < 0
? "table-success"
: ""}
>
<td class="font-monospace">{testName}</td>
<td class="text-end">
<span class={detail.score > 0 ? 'text-danger fw-bold' : detail.score < 0 ? 'text-success fw-bold' : 'text-muted'}>
{detail.score > 0 ? '+' : ''}{detail.score.toFixed(1)}
<span
class={detail.score > 0
? "text-danger fw-bold"
: detail.score < 0
? "text-success fw-bold"
: "text-muted"}
>
{detail.score > 0 ? "+" : ""}{detail.score.toFixed(1)}
</span>
</td>
<td class="small">{detail.description || ''}</td>
<td class="small">{detail.description || ""}</td>
</tr>
{/each}
</tbody>
@ -80,7 +90,11 @@
<strong>Tests Triggered:</strong>
<div class="mt-2">
{#each spamassassin.tests as test}
<span class="badge {$theme === 'light' ? 'bg-light text-dark' : 'bg-secondary'} me-1 mb-1">{test}</span>
<span
class="badge {$theme === 'light'
? 'bg-light text-dark'
: 'bg-secondary'} me-1 mb-1">{test}</span
>
{/each}
</div>
</div>
@ -89,7 +103,10 @@
{#if spamassassin.report}
<details class="mt-3">
<summary class="cursor-pointer fw-bold">Raw Report</summary>
<pre class="mt-2 small {$theme === 'light' ? 'bg-light' : 'bg-secondary'} p-3 rounded">{spamassassin.report}</pre>
<pre
class="mt-2 small {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} p-3 rounded">{spamassassin.report}</pre>
</details>
{/if}
</div>

View file

@ -11,8 +11,8 @@
// Check if DMARC has strict policy (quarantine or reject)
const dmarcStrict = $derived(
dmarcRecord?.valid &&
dmarcRecord?.policy &&
(dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject")
dmarcRecord?.policy &&
(dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject"),
);
// Compute overall validity
@ -43,7 +43,11 @@
<span class="badge bg-secondary">SPF</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">SPF specifies which mail servers are authorized to send emails on behalf of your domain. Receiving servers check the sender's IP address against your SPF record to prevent email spoofing.</p>
<p class="card-text small text-muted mb-0">
SPF specifies which mail servers are authorized to send emails on behalf of your
domain. Receiving servers check the sender's IP address against your SPF record to
prevent email spoofing.
</p>
</div>
<div class="list-group list-group-flush">
{#each spfRecords as spf, index}
@ -76,18 +80,31 @@
{:else if spf.all_qualifier === "?"}
<span class="badge bg-warning">Neutral (?all)</span>
{/if}
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))}
<div class="alert small mt-2" class:alert-warning={spf.all_qualifier !== '-'} class:alert-success={spf.all_qualifier === '-'}>
{#if spf.all_qualifier === '-'}
All unauthorized servers will be rejected. This is the recommended strict policy.
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes("redirect="))}
<div
class="alert small mt-2"
class:alert-warning={spf.all_qualifier !== "-"}
class:alert-success={spf.all_qualifier === "-"}
>
{#if spf.all_qualifier === "-"}
All unauthorized servers will be rejected. This is the
recommended strict policy.
{:else if dmarcStrict}
While your DMARC {dmarcRecord?.policy} policy provides some protection, consider using <code>-all</code> for better security with some old mailbox providers.
{:else if spf.all_qualifier === '~'}
Unauthorized servers will softfail. Consider using <code>-all</code> for stricter policy, though this rarely affects legitimate email deliverability.
{:else if spf.all_qualifier === '+'}
All servers are allowed to send email. This severely weakens email authentication. Use <code>-all</code> for strict policy.
{:else if spf.all_qualifier === '?'}
No statement about unauthorized servers. Use <code>-all</code> for strict policy to prevent spoofing.
While your DMARC {dmarcRecord?.policy} policy provides some protection,
consider using <code>-all</code> for better security with some
old mailbox providers.
{:else if spf.all_qualifier === "~"}
Unauthorized servers will softfail. Consider using <code
>-all</code
> for stricter policy, though this rarely affects legitimate
email deliverability.
{:else if spf.all_qualifier === "+"}
All servers are allowed to send email. This severely weakens
email authentication. Use <code>-all</code> for strict policy.
{:else if spf.all_qualifier === "?"}
No statement about unauthorized servers. Use <code
>-all</code
> for strict policy to prevent spoofing.
{/if}
</div>
{/if}
@ -95,14 +112,16 @@
{/if}
{#if spf.record}
<div class="mb-2">
<strong>Record:</strong><br>
<strong>Record:</strong><br />
<code class="d-block mt-1 text-break">{spf.record}</code>
</div>
{/if}
{#if spf.error}
<div class="alert alert-{spf.valid ? 'warning' : 'danger'} mb-0 mt-2">
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"></i>
<strong>{spf.valid ? 'Warning:' : 'Error:'}</strong> {spf.error}
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"
></i>
<strong>{spf.valid ? "Warning:" : "Error:"}</strong>
{spf.error}
</div>
{/if}
</div>

View file

@ -113,7 +113,7 @@
} else if (spfResult === "temperror" || spfResult === "permerror") {
segments.push({
text: "encountered an error",
highlight: { color: "warning", bold: true },
highlight: { color: "danger", bold: true },
link: "#authentication-spf",
});
segments.push({ text: ", check your SPF record configuration" });
@ -131,14 +131,22 @@
}
// SPF DNS record check
const spfRecord = report.dns_results?.spf_record;
if (spfRecord && !spfRecord.valid && spfRecord.record) {
segments.push({ text: ". Your SPF record is " });
segments.push({
text: "invalid",
highlight: { color: "danger", bold: true },
link: "#dns-spf",
});
const spfRecords = report.dns_results?.spf_records;
if (spfRecords && spfRecords.length > 0) {
const invalidSpfRecords = spfRecords.filter((r) => !r.valid && r.record);
if (invalidSpfRecords.length > 0) {
segments.push({ text: ". Your SPF record" });
if (invalidSpfRecords.length > 1) {
segments.push({ text: "s are " });
} else {
segments.push({ text: " is " });
}
segments.push({
text: "invalid",
highlight: { color: "danger", bold: true },
link: "#dns-spf",
});
}
}
// IP Reverse DNS (iprev) check
@ -310,7 +318,9 @@
// BIMI
const bimiResult = report.authentication?.bimi;
if (
(dmarcRecord && dmarcRecord.valid && dmarcRecord.policy != "none") &&
dmarcRecord &&
dmarcRecord.valid &&
dmarcRecord.policy != "none" &&
(!bimiResult || bimiResult.result !== "skipped")
) {
const bimiRecord = report.dns_results?.bimi_record;
@ -321,10 +331,10 @@
highlight: { color: "good", bold: true },
link: "#dns-bimi",
});
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
segments.push({ text: " declined to participate" });
} else if (bimiResult?.result !== "fail") {
segments.push({ text: " but" });
} else if (bimiResult?.result === "fail") {
segments.push({ text: " but " });
segments.push({
text: "has issues",
highlight: { color: "danger", bold: true },
@ -412,6 +422,17 @@
});
}
// One-click unsubscribe check
const unsubscribeMethods = report.content_analysis?.unsubscribe_methods;
if (unsubscribeMethods && unsubscribeMethods.length > 0 && !unsubscribeMethods.includes("one-click")) {
segments.push({ text: ". This email could benefit from " });
segments.push({
text: "one-click unsubscribe",
highlight: { color: "warning", bold: true },
link: "#content-details",
});
}
// Content/spam assessment
const spamAssassin = report.spamassassin;
const contentScore = report.summary?.content_score || 0;
@ -515,19 +536,39 @@
{#if segment.link}
<a
href={segment.link}
class="summary-link {segment.highlight ? getColorClass(segment.highlight.color) : ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
class="summary-link {segment.highlight
? getColorClass(segment.highlight.color)
: ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight
?.emphasis
? 'fst-italic'
: ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
>
{segment.text}
</a>
{:else if segment.highlight}
<span class="{getColorClass(segment.highlight.color)} {segment.highlight.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}">
<span
class="{getColorClass(segment.highlight.color)} {segment.highlight.bold
? 'highlighted'
: ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment
.highlight?.monospace
? 'font-monospace'
: ''}"
>
{segment.text}
</span>
{:else}
{segment.text}
{/if}
{/each}
Overall, your email received a grade <GradeDisplay grade={report.grade} score={report.score} size="inline" />{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}: you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}: you could have delivery issues with common providers.{:else if report.grade == "F"}: it will most likely be rejected by most providers.{:else}!{/if} Check the details below 🔽
Overall, your email received a grade <GradeDisplay
grade={report.grade}
score={report.score}
size="inline"
/>{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}:
you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}:
you could have delivery issues with common providers.{:else if report.grade == "F"}:
it will most likely be rejected by most providers.{:else}!{/if} Check the details below
🔽
</p>
{@render children?.()}
</div>

View file

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

View file

@ -1,17 +1,27 @@
// Component exports
export { default as FeatureCard } from "./FeatureCard.svelte";
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
export { default as ScoreCard } from "./ScoreCard.svelte";
export { default as SummaryCard } from "./SummaryCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as PendingState } from "./PendingState.svelte";
export { default as AuthenticationCard } from "./AuthenticationCard.svelte";
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte";
export { default as BlacklistCard } from "./BlacklistCard.svelte";
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
export { default as TinySurvey } from "./TinySurvey.svelte";
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as EmailPathCard } from "./EmailPathCard.svelte";
export { default as ErrorDisplay } from "./ErrorDisplay.svelte";
export { default as FeatureCard } from "./FeatureCard.svelte";
export { default as GradeDisplay } from "./GradeDisplay.svelte";
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
export { default as Logo } from "./Logo.svelte";
export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte";
export { default as PendingState } from "./PendingState.svelte";
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
export { default as ScoreCard } from "./ScoreCard.svelte";
export { default as RspamdCard } from "./RspamdCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
export { default as SummaryCard } from "./SummaryCard.svelte";
export { default as TinySurvey } from "./TinySurvey.svelte";
export { default as WhitelistCard } from "./WhitelistCard.svelte";

View file

@ -24,11 +24,14 @@ import { writable } from "svelte/store";
interface AppConfig {
report_retention?: number;
survey_url?: string;
custom_logo_url?: string;
rbls?: string[];
}
const defaultConfig: AppConfig = {
report_retention: 0,
survey_url: "",
rbls: [],
};
function getConfigFromScriptTag(): AppConfig | null {

View file

@ -1,11 +1,32 @@
import { writable } from "svelte/store";
// 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/>.
import { browser } from "$app/environment";
import { writable } from "svelte/store";
const getInitialTheme = () => {
if (!browser) return "light";
const stored = localStorage.getItem("theme");
if (stored) return stored;
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { page } from "$app/stores";
import { page } from "$app/state";
import { ErrorDisplay } from "$lib/components";
let status = $derived($page.status);
let message = $derived($page.error?.message || "An unexpected error occurred");
let status = $derived(page.status);
let message = $derived(page.error?.message || "An unexpected error occurred");
function getErrorTitle(status: number): string {
switch (status) {

View file

@ -1,11 +1,12 @@
<script lang="ts">
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "bootstrap/dist/css/bootstrap.min.css";
import "../app.css";
import favicon from '$lib/assets/favicon.svg';
import favicon from "$lib/assets/favicon.svg";
import Logo from "$lib/components/Logo.svelte";
import { appConfig } from "$lib/stores/config";
import { theme } from "$lib/stores/theme";
import { onMount } from "svelte";
@ -25,15 +26,19 @@
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="icon" href={favicon} />
</svelte:head>
<div class="min-vh-100 d-flex flex-column">
<nav class="navbar navbar-expand-lg navbar-light shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" href="/">
<i class="bi bi-envelope-check me-2"></i>
<Logo color={$theme === "light" ? "black" : "white"} />
{#if $appConfig.custom_logo_url}
<img src={$appConfig.custom_logo_url} alt="Logo" style="height: 25px;" />
{:else}
<i class="bi bi-envelope-check me-2"></i>
<Logo color={$theme === "light" ? "black" : "white"} />
{/if}
</a>
<div>
<span class="d-none d-md-inline navbar-text text-primary small">
@ -55,7 +60,26 @@
{@render children?.()}
</main>
<footer class="pt-3 pb-2 bg-dark text-light">
<footer
id="footer-classic"
class="px-4 px-md-5 py-2 bg-tertiary d-flex justify-content-between"
>
<a href="https://happydeliver.org/" target="_blank">Powered by happyDeliver</a>
<ul class="d-flex footer-nav">
<li>
<a
href="https://git.happydomain.org/happydeliver/-/blob/master/api/openapi.yaml?ref_type=heads"
target="_blank"
>
API
</a>
</li>
<li><a href="https://git.happydomain.org/happydeliver" target="_blank">Git</a></li>
<li><a href="https://feedback.happydeliver.org/" target="_blank">Feedback</a></li>
</ul>
</footer>
<footer id="footer-happydomain" class="d-none pt-3 pb-2 bg-dark text-light">
<div class="container mb-4">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4">
<div class="col">
@ -144,6 +168,27 @@
border-top: 3px solid #9332bb;
}
footer a {
text-decoration: none;
}
.footer-nav {
list-style: none;
padding: 0;
margin: 0;
gap: 0;
}
.footer-nav li {
display: flex;
align-items: center;
}
.footer-nav li:not(:last-child)::after {
content: "|";
margin: 0 0.5rem;
}
.footer-links {
list-style: none;
padding: 0;
@ -155,7 +200,6 @@
.footer-links a {
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
transition: color 0.3s;
}

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { createTest as apiCreateTest } from "$lib/api";
import { appConfig } from "$lib/stores/config";
import { FeatureCard, HowItWorksStep } from "$lib/components";
import { appConfig } from "$lib/stores/config";
let loading = $state(false);
let error = $state<string | null>(null);
@ -233,6 +234,17 @@
{/if}
</button>
</div>
<div class="text-center mt-4">
<a href="/domain" class="btn btn-secondary btn-lg me-2">
<i class="bi bi-globe me-2"></i>
Test Domain Only
</a>
<a href="/blacklist" class="btn btn-secondary btn-lg">
<i class="bi bi-shield-exclamation me-2"></i>
Check IP Blacklist
</a>
</div>
</div>
</section>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { appConfig } from "$lib/stores/config";
let ip = $state("");
let error = $state<string | null>(null);
function handleSubmit() {
error = null;
if (!ip.trim()) {
error = "Please enter an IP address";
return;
}
// Basic IPv4/IPv6 validation
const ipv4Pattern =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Pattern =
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
if (!ipv4Pattern.test(ip.trim()) && !ipv6Pattern.test(ip.trim())) {
error = "Please enter a valid IPv4 or IPv6 address (e.g., 192.0.2.1)";
return;
}
// Navigate to the blacklist check page
goto(`/blacklist/${encodeURIComponent(ip.trim())}`);
}
function handleKeyPress(event: KeyboardEvent) {
if (event.key === "Enter") {
handleSubmit();
}
}
</script>
<svelte:head>
<title>Blacklist Check - happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Header -->
<div class="text-center mb-5">
<h1 class="display-4 fw-bold mb-3">
<i class="bi bi-shield-exclamation me-2"></i>
Check IP Blacklist Status
</h1>
<p class="lead text-muted">
Test an IP address against multiple DNS-based blacklists (RBLs) to check its
reputation.
</p>
</div>
<!-- Input Form -->
<div class="card shadow-lg border-0 mb-5">
<div class="card-body p-5">
<h2 class="h5 mb-4">Enter IP Address</h2>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text bg-light">
<i class="bi bi-hdd-network"></i>
</span>
<input
type="text"
class="form-control"
placeholder="192.0.2.1 or 2001:db8::1"
bind:value={ip}
onkeypress={handleKeyPress}
autofocus
/>
<button
class="btn btn-primary px-5"
onclick={handleSubmit}
disabled={!ip.trim()}
>
<i class="bi bi-search me-2"></i>
Check
</button>
</div>
{#if error}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
{/if}
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Enter an IPv4 address (e.g., 192.0.2.1) or IPv6 address (e.g., 2001:db8::1)
</small>
</div>
</div>
<!-- Info Section -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100 border-0 bg-light">
<div class="card-body">
<h3 class="h6 mb-3">
<i class="bi bi-check-circle-fill text-success me-2"></i>
What's Checked
</h3>
<ul class="list-unstyled mb-0 small">
{#each $appConfig.rbls as rbl}
<li class="mb-2">
<i class="bi bi-arrow-right me-2"></i>{rbl}
</li>
{/each}
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-0 bg-light">
<div class="card-body">
<h3 class="h6 mb-3">
<i class="bi bi-info-circle-fill text-primary me-2"></i>
Why Check Blacklists?
</h3>
<p class="small mb-2">
DNS-based blacklists (RBLs) are used by email servers to identify
and block spam sources. Being listed can severely impact email
deliverability.
</p>
<p class="small mb-3">
This tool checks your IP against multiple popular RBLs to help you:
</p>
<ul class="list-unstyled mb-3 small">
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Monitor IP reputation
</li>
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Identify deliverability
issues
</li>
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Take corrective action
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Additional Info -->
<div class="alert alert-info border-0">
<h3 class="h6 mb-2">
<i class="bi bi-lightbulb me-2"></i>
Need Complete Email Analysis?
</h3>
<p class="small mb-2">
For comprehensive deliverability testing including DKIM verification, content
analysis, spam scoring, and more:
</p>
<a href="/" class="btn btn-sm btn-outline-primary">
<i class="bi bi-envelope-plus me-1"></i>
Send Test Email
</a>
</div>
</div>
</div>
</div>
<style>
.card {
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.1) !important;
}
.input-group-lg .form-control {
font-size: 1.1rem;
}
.input-group-text {
border-right: none;
}
.input-group .form-control {
border-left: none;
border-right: none;
}
.input-group .form-control:focus {
box-shadow: none;
}
</style>

View file

@ -0,0 +1,272 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { checkBlacklist } from "$lib/api";
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components";
import { theme } from "$lib/stores/theme";
let ip = $derived($page.params.ip);
let loading = $state(true);
let error = $state<string | null>(null);
let result = $state<BlacklistCheckResponse | null>(null);
async function analyzeIP() {
loading = true;
error = null;
result = null;
if (!ip) {
error = "IP parameter is missing";
loading = false;
return;
}
try {
const response = await checkBlacklist({
body: { ip: ip },
});
if (response.response.ok) {
result = response.data ?? null;
} else if (response.error) {
error = response.error.message || "Failed to check IP address";
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to check IP address";
} finally {
loading = false;
}
}
onMount(() => {
analyzeIP();
});
</script>
<svelte:head>
<title>{ip} - Blacklist Check - happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row">
<div class="col-lg-10 mx-auto">
<!-- Header -->
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between">
<h1 class="h2 mb-0">
<i class="bi bi-shield-exclamation me-2"></i>
Blacklist Analysis
</h1>
<a href="/blacklist" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>
Check Another IP
</a>
</div>
</div>
{#if loading}
<!-- Loading State -->
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h3 class="h5">Checking {ip}...</h3>
<p class="text-muted mb-0">Querying DNS-based blacklists</p>
</div>
</div>
{:else if error}
<!-- Error State -->
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"
></i>
<h3 class="h4 mt-4">Check Failed</h3>
<p class="text-muted mb-4">{error}</p>
<button class="btn btn-primary" onclick={analyzeIP}>
<i class="bi bi-arrow-clockwise me-2"></i>
Try Again
</button>
</div>
</div>
{:else if result}
<!-- Results -->
<div class="fade-in">
<!-- Score Summary Card -->
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
<h2 class="h2 mb-2">
<span class="font-monospace text-truncate">{result.ip}</span
>
</h2>
{#if result.listed_count === 0}
<div class="alert alert-success mb-0 d-inline-block">
<i class="bi bi-check-circle me-2"></i>
<strong>Not Listed</strong>
<p class="d-inline mb-0 mt-1 small">
This IP address is not listed on any checked
blacklists.
</p>
</div>
{:else}
<div class="alert alert-danger mb-0 d-inline-block">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong
>Listed on {result.listed_count} blacklist{result.listed_count >
1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
This IP address is listed on {result.listed_count} of
{result.blacklists.length} checked blacklist{result
.blacklists.length > 1
? "s"
: ""}.
</p>
</div>
{/if}
</div>
<div class="offset-md-3 col-md-3 text-center">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay score={result.score} grade={result.grade} />
<small class="text-muted d-block">Blacklist Score</small>
</div>
</div>
</div>
<div class="d-flex justify-content-end me-lg-5">
<TinySurvey
class="bg-primary-subtle rounded-4 p-3 text-center"
source={"rbl-" + result.ip}
/>
</div>
</div>
</div>
<div class="row">
<!-- Blacklist Results Card -->
<div class="col col-lg-6">
<BlacklistCard
blacklists={{ [result.ip]: result.blacklists }}
blacklistScore={result.score}
blacklistGrade={result.grade}
/>
</div>
<!-- Whitelist Results Card -->
{#if result.whitelists && result.whitelists.length > 0}
<div class="col col-lg-6">
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
</div>
{/if}
</div>
<!-- Information Card -->
<div class="card shadow-sm mt-4">
<div class="card-body">
<h3 class="h5 mb-3">
<i class="bi bi-info-circle me-2"></i>
What This Means
</h3>
{#if result.listed_count === 0}
<p class="mb-3">
<strong>Good news!</strong> This IP address is not currently listed
on any of the checked DNS-based blacklists (RBLs). This indicates
a good sender reputation and should not negatively impact email deliverability.
</p>
{:else}
<p class="mb-3">
<strong>Warning:</strong> This IP address is listed on {result.listed_count}
blacklist{result.listed_count > 1 ? "s" : ""}. Being listed can
significantly impact email deliverability as many mail servers
use these blacklists to filter incoming mail.
</p>
<div class="alert alert-warning">
<h4 class="h6 mb-2">Recommended Actions:</h4>
<ul class="mb-0 small">
<li>
Investigate the cause of the listing (compromised
system, spam complaints, etc.)
</li>
<li>
Fix any security issues or stop sending practices that
led to the listing
</li>
<li>
Request delisting from each RBL (check their websites
for removal procedures)
</li>
<li>
Monitor your IP reputation regularly to prevent future
listings
</li>
</ul>
</div>
{/if}
</div>
</div>
<!-- Next Steps -->
<div class="card shadow-sm border-primary mt-4">
<div class="card-body">
<h3 class="h5 mb-3">
<i class="bi bi-lightbulb me-2"></i>
Want Complete Email Analysis?
</h3>
<p class="mb-3">
This blacklist check tests IP reputation only. For comprehensive
deliverability testing including DKIM verification, content
analysis, spam scoring, and DNS configuration:
</p>
<a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i>
Send Test Email
</a>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.summary-card {
transition: transform 0.2s ease;
}
.summary-card:hover {
transform: scale(1.05);
}
.table td {
vertical-align: middle;
}
.badge {
font-size: 0.75rem;
padding: 0.35rem 0.65rem;
}
</style>

View file

@ -0,0 +1,187 @@
<script lang="ts">
import { goto } from "$app/navigation";
let domain = $state("");
let error = $state<string | null>(null);
function handleSubmit() {
error = null;
if (!domain.trim()) {
error = "Please enter a domain name";
return;
}
// Basic domain validation
const domainPattern =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*$/;
if (!domainPattern.test(domain.trim())) {
error = "Please enter a valid domain name (e.g., example.com)";
return;
}
// Navigate to the domain test page
goto(`/domain/${encodeURIComponent(domain.trim())}`);
}
function handleKeyPress(event: KeyboardEvent) {
if (event.key === "Enter") {
handleSubmit();
}
}
</script>
<svelte:head>
<title>Domain Test - happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Header -->
<div class="text-center mb-5">
<h1 class="display-4 fw-bold mb-3">
<i class="bi bi-globe me-2"></i>
Test Domain Configuration
</h1>
<p class="lead text-muted">
Check your domain's email DNS records (MX, SPF, DMARC, BIMI) without sending an
email.
</p>
</div>
<!-- Input Form -->
<div class="card shadow-lg border-0 mb-5">
<div class="card-body p-5">
<h2 class="h5 mb-4">Enter Domain Name</h2>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text bg-light">
<i class="bi bi-at"></i>
</span>
<input
type="text"
class="form-control"
placeholder="example.com"
bind:value={domain}
onkeypress={handleKeyPress}
autofocus
/>
<button
class="btn btn-primary px-5"
onclick={handleSubmit}
disabled={!domain.trim()}
>
<i class="bi bi-search me-2"></i>
Analyze
</button>
</div>
{#if error}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
{/if}
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Enter a domain name like "example.com" or "mail.example.org"
</small>
</div>
</div>
<!-- Info Section -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100 border-0 bg-light">
<div class="card-body">
<h3 class="h6 mb-3">
<i class="bi bi-check-circle-fill text-success me-2"></i>
What's Checked
</h3>
<ul class="list-unstyled mb-0 small">
<li class="mb-2">
<i class="bi bi-arrow-right me-2"></i>MX Records
</li>
<li class="mb-2">
<i class="bi bi-arrow-right me-2"></i>SPF Records
</li>
<li class="mb-2">
<i class="bi bi-arrow-right me-2"></i>DMARC Policy
</li>
<li class="mb-2">
<i class="bi bi-arrow-right me-2"></i>BIMI Support
</li>
<li class="mb-0">
<i class="bi bi-arrow-right me-2"></i>Disposable Domain Check
</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-0 bg-light">
<div class="card-body">
<h3 class="h6 mb-3">
<i class="bi bi-info-circle-fill text-primary me-2"></i>
Need More?
</h3>
<p class="small mb-2">
For complete email deliverability analysis including:
</p>
<ul class="list-unstyled mb-3 small">
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>DKIM Verification
</li>
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Content & Header Analysis
</li>
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Spam Scoring
</li>
<li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Blacklist Checks
</li>
</ul>
<a href="/" class="btn btn-sm btn-outline-primary">
<i class="bi bi-envelope-plus me-1"></i>
Send Test Email
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.card {
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.1) !important;
}
.input-group-lg .form-control {
font-size: 1.1rem;
}
.input-group-text {
border-right: none;
}
.input-group .form-control {
border-left: none;
border-right: none;
}
.input-group .form-control:focus {
box-shadow: none;
}
</style>

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { page } from "$app/state";
import { onMount } from "svelte";
import { testDomain } from "$lib/api";
import type { DomainTestResponse } from "$lib/api/types.gen";
import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components";
import { theme } from "$lib/stores/theme";
let domain = $derived(page.params.domain);
let loading = $state(true);
let error = $state<string | null>(null);
let result = $state<DomainTestResponse | null>(null);
async function analyzeDomain() {
loading = true;
error = null;
result = null;
if (!domain) {
error = "Domain parameter is missing";
loading = false;
return;
}
try {
const response = await testDomain({
body: { domain: domain },
});
if (response.data) {
result = response.data;
} else if (response.error) {
error = response.error.message || "Failed to analyze domain";
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to analyze domain";
} finally {
loading = false;
}
}
onMount(() => {
analyzeDomain();
});
</script>
<svelte:head>
<title>{domain} - Domain Test - happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row">
<div class="col-lg-10 mx-auto">
<!-- Header -->
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between">
<h1 class="h2 mb-0">
<i class="bi bi-globe me-2"></i>
Domain Analysis
</h1>
<a href="/domain" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>
Test Another Domain
</a>
</div>
</div>
{#if loading}
<!-- Loading State -->
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h3 class="h5">Analyzing {domain}...</h3>
<p class="text-muted mb-0">Checking DNS records and configuration</p>
</div>
</div>
{:else if error}
<!-- Error State -->
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"
></i>
<h3 class="h4 mt-4">Analysis Failed</h3>
<p class="text-muted mb-4">{error}</p>
<button class="btn btn-primary" onclick={analyzeDomain}>
<i class="bi bi-arrow-clockwise me-2"></i>
Try Again
</button>
</div>
</div>
{:else if result}
<!-- Results -->
<div class="fade-in">
<!-- Score Summary Card -->
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
<h2 class="h2 mb-2">
<span class="font-monospace">{result.domain}</span>
</h2>
{#if result.is_disposable}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Disposable Email Provider Detected</strong>
<p class="mb-0 mt-1 small">
This domain is a known temporary/disposable email
service. Emails from this domain may have lower
deliverability.
</p>
</div>
{:else}
<p class="text-muted mb-0">Domain Configuration Score</p>
{/if}
</div>
<div class="offset-md-3 col-md-3 text-center">
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay score={result.score} grade={result.grade} />
<small class="text-muted d-block">DNS</small>
</div>
</div>
</div>
<div class="d-flex justify-content-end me-lg-5 mt-3">
<TinySurvey
class="bg-primary-subtle rounded-4 p-3 text-center"
source={"domain-" + result.domain}
/>
</div>
</div>
</div>
<!-- DNS Records Card -->
<DnsRecordsCard
dnsResults={result.dns_results}
dnsScore={result.score}
dnsGrade={result.grade}
domainOnly={true}
/>
<!-- Next Steps -->
<div class="card shadow-sm border-primary mt-4">
<div class="card-body">
<h3 class="h5 mb-3">
<i class="bi bi-lightbulb me-2"></i>
Want Complete Email Analysis?
</h3>
<p class="mb-3">
This domain-only test checks DNS configuration. For comprehensive
deliverability testing including DKIM verification, content
analysis, spam scoring, and blacklist checks:
</p>
<a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i>
Send a Test Email
</a>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
border: none;
}
</style>

View file

@ -1,22 +1,28 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { page } from "$app/state";
import { getTest, getReport, reanalyzeReport } from "$lib/api";
import type { Test, Report } from "$lib/api/types.gen";
import { onDestroy } from "svelte";
import { getReport, getTest, reanalyzeReport } from "$lib/api";
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
import {
ScoreCard,
SummaryCard,
SpamAssassinCard,
PendingState,
AuthenticationCard,
DnsRecordsCard,
BlacklistCard,
ContentAnalysisCard,
HeaderAnalysisCard,
TinySurvey,
DnsRecordsCard,
EmailPathCard,
ErrorDisplay,
HeaderAnalysisCard,
PendingState,
RspamdCard,
ScoreCard,
SpamAssassinCard,
SummaryCard,
TinySurvey,
WhitelistCard,
} from "$lib/components";
type BlacklistRecords = Record<string, BlacklistCheck[]>;
let testId = $derived(page.params.test);
let test = $state<Test | null>(null);
let report = $state<Report | null>(null);
@ -84,8 +90,8 @@
const reportResponse = await getReport({ path: { id: testId } });
if (reportResponse.data) {
report = reportResponse.data;
stopPolling();
}
stopPolling();
}
} else if (testResponse.error) {
handleApiError(testResponse.error, "Failed to fetch test");
@ -188,7 +194,13 @@
</script>
<svelte:head>
<title>{report ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ''} ${report.test_id?.slice(0, 7) || ''}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver</title>
<title>
{report
? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ""} ${report.test_id?.slice(0, 7) || ""}`
: test
? `Test ${test.id.slice(0, 7)}`
: "Loading..."} - happyDeliver
</title>
</svelte:head>
<div class="container py-5">
@ -283,6 +295,15 @@
</div>
</div>
<!-- Received Chain -->
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
<div class="row mb-4" id="received-chain">
<div class="col-12">
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
</div>
</div>
{/if}
<!-- DNS Records -->
{#if report.dns_results}
<div class="row mb-4" id="dns">
@ -313,17 +334,45 @@
{/if}
<!-- Blacklist Checks -->
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
<div class="row mb-4" id="blacklist">
<div class="col-12">
<BlacklistCard
blacklists={report.blacklists}
blacklistGrade={report.summary?.blacklist_grade}
blacklistScore={report.summary?.blacklist_score}
receivedChain={report.header_analysis?.received_chain}
/>
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
<BlacklistCard
{blacklists}
blacklistGrade={report.summary?.blacklist_grade}
blacklistScore={report.summary?.blacklist_score}
/>
{/snippet}
<!-- Whitelist Checks -->
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
<WhitelistCard {whitelists} />
{/snippet}
<!-- Blacklist & Whitelist Checks -->
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
<div class="row mb-4">
<div class="col-6" id="blacklist">
{@render blacklistChecks(report.blacklists, report)}
</div>
<div class="col-6" id="whitelist">
{@render whitelistChecks(report.whitelists)}
</div>
</div>
{:else}
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
<div class="row mb-4" id="blacklist">
<div class="col-12">
{@render blacklistChecks(report.blacklists, report)}
</div>
</div>
{/if}
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
<div class="row mb-4" id="whitelist">
<div class="col-12">
{@render whitelistChecks(report.whitelists)}
</div>
</div>
{/if}
{/if}
<!-- Header Analysis -->
@ -335,22 +384,24 @@
headerAnalysis={report.header_analysis}
headerGrade={report.summary?.header_grade}
headerScore={report.summary?.header_score}
xAlignedFrom={report.authentication?.x_aligned_from}
/>
</div>
</div>
{/if}
<!-- Additional Information -->
{#if report.spamassassin}
<!-- Spam filter analysis -->
{#if report.spamassassin || report.rspamd}
<div class="row mb-4" id="spam">
<div class="col-12">
<SpamAssassinCard
spamassassin={report.spamassassin}
spamGrade={report.summary?.spam_grade}
spamScore={report.summary?.spam_score}
/>
</div>
{#if report.spamassassin}
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
<SpamAssassinCard spamassassin={report.spamassassin} />
</div>
{/if}
{#if report.rspamd}
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
<RspamdCard rspamd={report.rspamd} />
</div>
{/if}
</div>
{/if}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After