Compare commits

..

2 commits

Author SHA1 Message Date
5d1dce038f WIP add dnssec test
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-18 13:38:31 +07:00
6ae2c2463d Use peterzen/goresolver as resolver 2025-11-17 11:00:36 +07:00
65 changed files with 1772 additions and 3210 deletions

View file

@ -34,7 +34,7 @@ RUN go generate ./... && \
# Stage 3: Prepare perl and spamass-milt # Stage 3: Prepare perl and spamass-milt
FROM alpine:3 AS pl FROM alpine:3 AS pl
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
apk add --no-cache \ apk add --no-cache \
build-base \ build-base \
libmilter-dev \ libmilter-dev \
@ -55,7 +55,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
perl-json-xs \ perl-json-xs \
perl-list-moreutils \ perl-list-moreutils \
perl-moose \ perl-moose \
perl-net-idn-encode@edge \ perl-net-idn-encode@testing \
perl-net-ssleay \ perl-net-ssleay \
perl-netaddr-ip \ perl-netaddr-ip \
perl-package-stash \ perl-package-stash \
@ -86,7 +86,7 @@ RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milt
FROM alpine:3 FROM alpine:3
# Install all required packages # Install all required packages
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
apk add --no-cache \ apk add --no-cache \
bash \ bash \
ca-certificates \ ca-certificates \
@ -106,7 +106,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
perl-json-xs \ perl-json-xs \
perl-list-moreutils \ perl-list-moreutils \
perl-moose \ perl-moose \
perl-net-idn-encode@edge \ perl-net-idn-encode@testing \
perl-net-ssleay \ perl-net-ssleay \
perl-netaddr-ip \ perl-netaddr-ip \
perl-package-stash \ perl-package-stash \
@ -121,7 +121,6 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
perl-xml-libxml \ perl-xml-libxml \
postfix \ postfix \
postfix-pcre \ postfix-pcre \
rspamd \
spamassassin \ spamassassin \
spamassassin-client \ spamassassin-client \
supervisor \ supervisor \
@ -144,11 +143,8 @@ RUN mkdir -p /etc/happydeliver \
/var/lib/authentication_milter \ /var/lib/authentication_milter \
/var/spool/postfix/authentication_milter \ /var/spool/postfix/authentication_milter \
/var/spool/postfix/spamassassin \ /var/spool/postfix/spamassassin \
/var/spool/postfix/rspamd \
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ && 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 the built application
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
@ -158,7 +154,6 @@ RUN chmod +x /usr/local/bin/happyDeliver
COPY docker/postfix/ /etc/postfix/ COPY docker/postfix/ /etc/postfix/
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
COPY docker/spamassassin/ /etc/mail/spamassassin/ COPY docker/spamassassin/ /etc/mail/spamassassin/
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
COPY docker/supervisor/ /etc/supervisor/ COPY docker/supervisor/ /etc/supervisor/
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
@ -170,12 +165,7 @@ RUN chmod +x /entrypoint.sh
EXPOSE 25 8080 EXPOSE 25 8080
# Default configuration # Default configuration
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ 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
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 for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]

View file

@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a
## Features ## Features
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more - **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
- **REST API**: Full-featured API for creating tests and retrieving reports - **REST API**: Full-featured API for creating tests and retrieving reports
- **LMTP Server**: Built-in LMTP server for seamless MTA integration - **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 - **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
@ -26,7 +26,6 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha
- **Postfix MTA**: Receives emails on port 25 - **Postfix MTA**: Receives emails on port 25
- **authentication_milter**: Entreprise grade email authentication - **authentication_milter**: Entreprise grade email authentication
- **SpamAssassin**: Spam scoring and analysis - **SpamAssassin**: Spam scoring and analysis
- **rspamd**: Second spam filter for cross-validated scoring
- **happyDeliver API**: REST API server on port 8080 - **happyDeliver API**: REST API server on port 8080
- **SQLite Database**: Persistent storage for tests and reports - **SQLite Database**: Persistent storage for tests and reports
@ -38,7 +37,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
cd happydeliver cd happydeliver
# Edit docker-compose.yml to set your domain # Edit docker-compose.yml to set your domain
# Change HAPPYDELIVER_DOMAIN environment variable and hostname # Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
# Build and start # Build and start
docker-compose up -d docker-compose up -d
@ -64,54 +63,13 @@ docker run -d \
-p 25:25 \ -p 25:25 \
-p 8080:8080 \ -p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \ -e HAPPYDELIVER_DOMAIN=yourdomain.com \
--hostname mail.yourdomain.com \ -e HOSTNAME=mail.yourdomain.com \
-v $(pwd)/data:/var/lib/happydeliver \ -v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \ -v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest happydeliver:latest
``` ```
#### 3. Configure TLS Certificates (Optional but Recommended) #### 3. Configure Network and DNS
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 ##### Open SMTP Port
@ -163,7 +121,7 @@ The server will start on `http://localhost:8080` by default.
#### 3. Integrate with your existing e-mail setup #### 3. Integrate with your existing e-mail setup
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
Choose one of the following way to integrate happyDeliver in your existing setup: Choose one of the following way to integrate happyDeliver in your existing setup:
@ -270,7 +228,7 @@ The deliverability score is calculated from A to F based on:
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation - **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
- **Blacklist**: RBL/DNSBL checks - **Blacklist**: RBL/DNSBL checks
- **Headers**: Required headers, MIME structure, Domain alignment - **Headers**: Required headers, MIME structure, Domain alignment
- **Spam**: SpamAssassin and rspamd scores (combined 50/50) - **Spam**: SpamAssassin score
- **Content**: HTML quality, links, images, unsubscribe - **Content**: HTML quality, links, images, unsubscribe
## Funding ## Funding

View file

@ -333,8 +333,6 @@ components:
$ref: '#/components/schemas/AuthenticationResults' $ref: '#/components/schemas/AuthenticationResults'
spamassassin: spamassassin:
$ref: '#/components/schemas/SpamAssassinResult' $ref: '#/components/schemas/SpamAssassinResult'
rspamd:
$ref: '#/components/schemas/RspamdResult'
dns_results: dns_results:
$ref: '#/components/schemas/DNSResults' $ref: '#/components/schemas/DNSResults'
blacklists: blacklists:
@ -350,19 +348,6 @@ components:
listed: false listed: false
- rbl: "bl.spamcop.net" - rbl: "bl.spamcop.net"
listed: false listed: false
whitelists:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: Map of IP addresses to their DNS whitelist check results (informational only)
example:
"192.0.2.1":
- rbl: "list.dnswl.org"
listed: false
- rbl: "swl.spamhaus.org"
listed: false
content_analysis: content_analysis:
$ref: '#/components/schemas/ContentAnalysis' $ref: '#/components/schemas/ContentAnalysis'
header_analysis: header_analysis:
@ -416,7 +401,7 @@ components:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 100 maximum: 100
description: Spam filter score (SpamAssassin + rspamd combined, in percentage) description: SpamAssassin score (in percentage)
example: 15 example: 15
spam_grade: spam_grade:
type: string type: string
@ -789,7 +774,7 @@ components:
properties: properties:
result: result:
type: string type: string
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass]
description: Authentication result description: Authentication result
example: "pass" example: "pass"
domain: domain:
@ -858,17 +843,6 @@ components:
- is_spam - is_spam
- test_details - test_details
properties: 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: version:
type: string type: string
description: SpamAssassin version description: SpamAssassin version
@ -931,81 +905,6 @@ components:
description: Human-readable description of what this test checks description: Human-readable description of what this test checks
example: "Bayes spam probability is 0 to 1%" 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: DNSResults:
type: object type: object
required: required:
@ -1043,6 +942,10 @@ components:
$ref: '#/components/schemas/DMARCRecord' $ref: '#/components/schemas/DMARCRecord'
bimi_record: bimi_record:
$ref: '#/components/schemas/BIMIRecord' $ref: '#/components/schemas/BIMIRecord'
dnssec_enabled:
type: boolean
description: Whether the From domain has DNSSEC enabled with valid chain of trust
example: true
ptr_records: ptr_records:
type: array type: array
items: items:
@ -1346,7 +1249,7 @@ components:
type: object type: object
required: required:
- ip - ip
- blacklists - checks
- listed_count - listed_count
- score - score
- grade - grade
@ -1355,7 +1258,7 @@ components:
type: string type: string
description: The IP address that was checked description: The IP address that was checked
example: "192.0.2.1" example: "192.0.2.1"
blacklists: checks:
type: array type: array
items: items:
$ref: '#/components/schemas/BlacklistCheck' $ref: '#/components/schemas/BlacklistCheck'
@ -1375,8 +1278,3 @@ components:
enum: [A+, A, B, C, D, E, F] enum: [A+, A, B, C, D, E, F]
description: Letter grade representation of the score description: Letter grade representation of the score
example: "A+" example: "A+"
whitelists:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: List of DNS whitelist check results (informational only)

View file

@ -5,12 +5,12 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: happydomain/happydeliver:latest image: happydomain/happydeliver:latest
container_name: happydeliver container_name: happydeliver
# Set a hostname
hostname: mail.happydeliver.local hostname: mail.happydeliver.local
environment: environment:
# Set your domain # Set your domain and hostname
HAPPYDELIVER_DOMAIN: happydeliver.local DOMAIN: happydeliver.local
HOSTNAME: mail.happydeliver.local
ports: ports:
# SMTP port # SMTP port

View file

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

View file

@ -4,7 +4,7 @@ set -e
echo "Starting happyDeliver container..." echo "Starting happyDeliver container..."
# Get environment variables with defaults # Get environment variables with defaults
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
echo "Hostname: $HOSTNAME" echo "Hostname: $HOSTNAME"
@ -15,10 +15,6 @@ mkdir -p /var/spool/postfix/authentication_milter
chown mail:mail /var/spool/postfix/authentication_milter chown mail:mail /var/spool/postfix/authentication_milter
chmod 750 /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 # Create log directory
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
chown happydeliver:happydeliver /var/log/happydeliver chown happydeliver:happydeliver /var/log/happydeliver
@ -29,15 +25,6 @@ echo "Configuring Postfix..."
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/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 # Replace placeholders in configurations
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json 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 # OpenDKIM for DKIM verification
milter_default_action = accept milter_default_action = accept
milter_protocol = 6 milter_protocol = 6
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 smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock
non_smtpd_milters = $smtpd_milters non_smtpd_milters = $smtpd_milters
# SPF policy checking # SPF policy checking

View file

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

View file

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

View file

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

View file

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

View file

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

42
go.mod
View file

@ -9,7 +9,7 @@ require (
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.1.2 github.com/oapi-codegen/runtime v1.1.2
golang.org/x/net v0.50.0 golang.org/x/net v0.47.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@ -18,25 +18,25 @@ require (
require ( require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonpointer v0.22.2 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@ -46,7 +46,8 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/miekg/dns v1.1.4 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
@ -55,9 +56,10 @@ require (
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/peterzen/goresolver v1.0.2 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/quic-go/quic-go v0.56.0 // indirect
github.com/redis/go-redis/v9 v9.16.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@ -66,13 +68,13 @@ require (
github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect
go.uber.org/mock v0.6.0 // indirect go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.44.0 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

90
go.sum
View file

@ -10,10 +10,10 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 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.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@ -36,18 +36,18 @@ 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.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 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= 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-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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -56,15 +56,15 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 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 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -90,8 +90,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 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/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -118,8 +118,10 @@ 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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.4 h1:rCMZsU2ScVSYcAsOXgmC6+AKOK+6pmQTOcw03nfwYV0=
github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -154,14 +156,16 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/peterzen/goresolver v1.0.2 h1:UxRxk835Onz7Go4oPUsOptSmBlIvN/yJ2kv3Srr3hw4=
github.com/peterzen/goresolver v1.0.2/go.mod h1:LrWRiOeCYApgvR2OhpipNOeaE1yGfI+QQjpF0riJC8M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.16.0/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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
@ -198,30 +202,34 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222171317-cd391775e71e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -235,21 +243,23 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -262,8 +272,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.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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View file

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

View file

@ -41,7 +41,6 @@ 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.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
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 // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
} }

View file

@ -44,7 +44,6 @@ type Config struct {
ReportRetention time.Duration // How long to keep reports. 0 = keep forever ReportRetention time.Duration // How long to keep reports. 0 = keep forever
RateLimit uint // API rate limit (requests per second per IP) RateLimit uint // API rate limit (requests per second per IP)
SurveyURL url.URL // URL for user feedback survey SurveyURL url.URL // URL for user feedback survey
CustomLogoURL string // URL for custom logo image in the web UI
} }
// DatabaseConfig contains database connection settings // DatabaseConfig contains database connection settings
@ -65,7 +64,6 @@ type AnalysisConfig struct {
DNSTimeout time.Duration DNSTimeout time.Duration
HTTPTimeout time.Duration HTTPTimeout time.Duration
RBLs []string RBLs []string
DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one CheckAllIPs bool // Check all IPs found in headers, not just the first one
} }
@ -89,7 +87,6 @@ func DefaultConfig() *Config {
DNSTimeout: 5 * time.Second, DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second, HTTPTimeout: 10 * time.Second,
RBLs: []string{}, RBLs: []string{},
DNSWLs: []string{},
CheckAllIPs: false, // By default, only check the first IP CheckAllIPs: false, // By default, only check the first IP
}, },
} }

View file

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

View file

@ -27,7 +27,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@ -38,10 +37,8 @@ import (
// ContentAnalyzer analyzes email content (HTML, links, images) // ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct { type ContentAnalyzer struct {
Timeout time.Duration Timeout time.Duration
httpClient *http.Client 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 // NewContentAnalyzer creates a new content analyzer with configurable timeout
@ -113,13 +110,6 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
results.IsMultipart = len(email.Parts) > 1 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 // Get HTML and text parts
htmlParts := email.GetHTMLParts() htmlParts := email.GetHTMLParts()
textParts := email.GetTextParts() textParts := email.GetTextParts()
@ -341,14 +331,9 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
// isUnsubscribeLink checks if a link is an unsubscribe link // isUnsubscribeLink checks if a link is an unsubscribe link
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool { 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 // Check href for unsubscribe keywords
lowerHref := strings.ToLower(href) lowerHref := strings.ToLower(href)
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"} unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
for _, keyword := range unsubKeywords { for _, keyword := range unsubKeywords {
if strings.Contains(lowerHref, keyword) { if strings.Contains(lowerHref, keyword) {
return true return true
@ -454,8 +439,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
// Extract the actual destination domain/email based on scheme // Extract the actual destination domain/email based on scheme
var actualDomain string var actualDomain string
switch parsedURL.Scheme { if parsedURL.Scheme == "mailto" {
case "mailto":
// Extract email address from mailto: URL // Extract email address from mailto: URL
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=... // Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
mailtoAddr := parsedURL.Opaque mailtoAddr := parsedURL.Opaque
@ -473,8 +457,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
} else { } else {
return false // Invalid mailto return false // Invalid mailto
} }
case "http": } else if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
case "https":
// Check if URL has a host // Check if URL has a host
if parsedURL.Host == "" { if parsedURL.Host == "" {
return false return false
@ -486,7 +469,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
actualDomain = actualDomain[:idx] actualDomain = actualDomain[:idx]
} }
actualDomain = strings.ToLower(actualDomain) actualDomain = strings.ToLower(actualDomain)
default: } else {
// Skip checks for other URL schemes (tel, etc.) // Skip checks for other URL schemes (tel, etc.)
return false return false
} }
@ -509,8 +492,10 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
"email us", "contact us", "send email", "get in touch", "reach out", "email us", "contact us", "send email", "get in touch", "reach out",
"contact", "email", "write to us", "contact", "email", "write to us",
} }
if slices.Contains(genericTexts, linkText) { for _, generic := range genericTexts {
return false if linkText == generic {
return false
}
} }
// Extract domain-like patterns from link text using regex // Extract domain-like patterns from link text using regex
@ -577,8 +562,10 @@ func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) boo
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co", "bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
"buff.ly", "is.gd", "bl.ink", "short.io", "buff.ly", "is.gd", "bl.ink", "short.io",
} }
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) { for _, shortener := range shorteners {
return true if strings.ToLower(parsedURL.Host) == shortener {
return true
}
} }
// Check for excessive subdomains (possible obfuscation) // Check for excessive subdomains (possible obfuscation)
@ -737,7 +724,6 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
HasHtml: api.PtrTo(results.HTMLContent != ""), HasHtml: api.PtrTo(results.HTMLContent != ""),
HasPlaintext: api.PtrTo(results.TextContent != ""), HasPlaintext: api.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe), HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
} }
// Calculate text-to-image ratio (inverse of image-to-text) // Calculate text-to-image ratio (inverse of image-to-text)
@ -884,19 +870,8 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
// Unsubscribe methods // Unsubscribe methods
if results.HasUnsubscribe { if results.HasUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link) methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
} analysis.UnsubscribeMethods = &methods
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 return analysis

View file

@ -144,74 +144,6 @@ func TestIsUnsubscribeLink(t *testing.T) {
linkText: "Read more", linkText: "Read more",
expected: false, 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) analyzer := NewContentAnalyzer(5 * time.Second)

View file

@ -127,6 +127,12 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
// Check BIMI record (for From domain - branding is based on visible sender) // Check BIMI record (for From domain - branding is based on visible sender)
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default") results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
// Check DNSSEC status (for From domain)
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, fromDomain)
if err == nil {
results.DnssecEnabled = &dnssecEnabled
}
return results return results
} }
@ -149,6 +155,12 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
// Check BIMI record with default selector // Check BIMI record with default selector
results.BimiRecord = d.checkBIMIRecord(domain, "default") results.BimiRecord = d.checkBIMIRecord(domain, "default")
// Check DNSSEC status
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, domain)
if err == nil {
results.DnssecEnabled = &dnssecEnabled
}
return results return results
} }
@ -204,11 +216,16 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string
score := 0 score := 0
// DNSSEC: 10 points
if results.DnssecEnabled != nil && *results.DnssecEnabled {
score += 10
}
// PTR and Forward DNS: 20 points // PTR and Forward DNS: 20 points
score += 20 * d.calculatePTRScore(results, senderIP) / 100 score += 20 * d.calculatePTRScore(results, senderIP) / 100
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain) // MX Records: 10 points (5 for From domain, 5 for Return-Path domain)
score += 20 * d.calculateMXScore(results) / 100 score += 10 * d.calculateMXScore(results) / 100
// SPF Records: 20 points // SPF Records: 20 points
score += 20 * d.calculateSPFScore(results) / 100 score += 20 * d.calculateSPFScore(results) / 100

View file

@ -23,7 +23,12 @@ package analyzer
import ( import (
"context" "context"
"fmt"
"net" "net"
"strings"
"github.com/miekg/dns"
"github.com/peterzen/goresolver"
) )
// DNSResolver defines the interface for DNS resolution operations. // DNSResolver defines the interface for DNS resolution operations.
@ -43,38 +48,190 @@ type DNSResolver interface {
// LookupHost looks up the given hostname using the local resolver. // LookupHost looks up the given hostname using the local resolver.
// It returns a slice of that host's addresses (IPv4 and IPv6). // It returns a slice of that host's addresses (IPv4 and IPv6).
LookupHost(ctx context.Context, host string) ([]string, error) LookupHost(ctx context.Context, host string) ([]string, error)
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
// Returns true if the domain has DNSSEC configured and the chain of trust is valid.
IsDNSSECEnabled(ctx context.Context, domain string) (bool, error)
} }
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver. // StandardDNSResolver is the default DNS resolver implementation that uses goresolver with DNSSEC validation.
type StandardDNSResolver struct { type StandardDNSResolver struct {
resolver *net.Resolver resolver *goresolver.Resolver
} }
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings. // NewStandardDNSResolver creates a new StandardDNSResolver with DNSSEC validation support.
func NewStandardDNSResolver() DNSResolver { func NewStandardDNSResolver() DNSResolver {
// Pass /etc/resolv.conf to load default DNS configuration
resolver, err := goresolver.NewResolver("/etc/resolv.conf")
if err != nil {
panic(fmt.Sprintf("failed to initialize goresolver: %v", err))
}
return &StandardDNSResolver{ return &StandardDNSResolver{
resolver: &net.Resolver{ resolver: resolver,
PreferGo: true,
},
} }
} }
// LookupMX implements DNSResolver.LookupMX using net.Resolver. // LookupMX implements DNSResolver.LookupMX using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
return r.resolver.LookupMX(ctx, name) // Ensure the name ends with a dot for DNS queries
queryName := name
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeMX)
if err != nil {
return nil, err
}
mxRecords := make([]*net.MX, 0, len(rrs))
for _, rr := range rrs {
if mx, ok := rr.(*dns.MX); ok {
mxRecords = append(mxRecords, &net.MX{
Host: strings.TrimSuffix(mx.Mx, "."),
Pref: mx.Preference,
})
}
}
if len(mxRecords) == 0 {
return nil, fmt.Errorf("no MX records found for %s", name)
}
return mxRecords, nil
} }
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver. // LookupTXT implements DNSResolver.LookupTXT using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return r.resolver.LookupTXT(ctx, name) // Ensure the name ends with a dot for DNS queries
queryName := name
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeTXT)
if err != nil {
return nil, err
}
txtRecords := make([]string, 0, len(rrs))
for _, rr := range rrs {
if txt, ok := rr.(*dns.TXT); ok {
// Join all TXT strings (a single TXT record can have multiple strings)
txtRecords = append(txtRecords, strings.Join(txt.Txt, ""))
}
}
if len(txtRecords) == 0 {
return nil, fmt.Errorf("no TXT records found for %s", name)
}
return txtRecords, nil
} }
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver. // LookupAddr implements DNSResolver.LookupAddr using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return r.resolver.LookupAddr(ctx, addr) // Convert IP address to reverse DNS name (e.g., 1.0.0.127.in-addr.arpa.)
arpa, err := dns.ReverseAddr(addr)
if err != nil {
return nil, fmt.Errorf("invalid IP address: %w", err)
}
rrs, err := r.resolver.StrictNSQuery(arpa, dns.TypePTR)
if err != nil {
return nil, err
}
ptrRecords := make([]string, 0, len(rrs))
for _, rr := range rrs {
if ptr, ok := rr.(*dns.PTR); ok {
ptrRecords = append(ptrRecords, strings.TrimSuffix(ptr.Ptr, "."))
}
}
if len(ptrRecords) == 0 {
return nil, fmt.Errorf("no PTR records found for %s", addr)
}
return ptrRecords, nil
} }
// LookupHost implements DNSResolver.LookupHost using net.Resolver. // LookupHost implements DNSResolver.LookupHost using goresolver with DNSSEC validation.
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) { func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
return r.resolver.LookupHost(ctx, host) // Ensure the host ends with a dot for DNS queries
queryName := host
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
var allAddrs []string
// Query A records (IPv4)
rrsA, errA := r.resolver.StrictNSQuery(queryName, dns.TypeA)
if errA == nil {
for _, rr := range rrsA {
if a, ok := rr.(*dns.A); ok {
allAddrs = append(allAddrs, a.A.String())
}
}
}
// Query AAAA records (IPv6)
rrsAAAA, errAAAA := r.resolver.StrictNSQuery(queryName, dns.TypeAAAA)
if errAAAA == nil {
for _, rr := range rrsAAAA {
if aaaa, ok := rr.(*dns.AAAA); ok {
allAddrs = append(allAddrs, aaaa.AAAA.String())
}
}
}
// Return error only if both queries failed
if errA != nil && errAAAA != nil {
return nil, fmt.Errorf("failed to resolve host: IPv4 error: %v, IPv6 error: %v", errA, errAAAA)
}
if len(allAddrs) == 0 {
return nil, fmt.Errorf("no A or AAAA records found for %s", host)
}
return allAddrs, nil
}
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
// It uses DNSSEC validation to ensure the chain of trust is valid.
// Returns true if DNSSEC is properly configured and validated, false otherwise.
func (r *StandardDNSResolver) IsDNSSECEnabled(ctx context.Context, domain string) (bool, error) {
// Ensure the domain ends with a dot for DNS queries
queryName := domain
if !strings.HasSuffix(queryName, ".") {
queryName = queryName + "."
}
// Query for DNSKEY records with DNSSEC validation
// If this succeeds, it means:
// 1. The domain has DNSKEY records (DNSSEC is configured)
// 2. The DNSSEC chain of trust is valid (validated by StrictNSQuery)
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeDNSKEY)
if err != nil {
// DNSSEC is not enabled or validation failed
return false, nil
}
// Check if we got any DNSKEY records
if len(rrs) == 0 {
return false, nil
}
// Verify we actually have DNSKEY records (not just any RR type)
hasDNSKEY := false
for _, rr := range rrs {
if _, ok := rr.(*dns.DNSKEY); ok {
hasDNSKEY = true
break
}
}
return hasDNSKEY, nil
} }

View file

@ -0,0 +1,111 @@
// 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"
"testing"
)
func TestIsDNSSECEnabled(t *testing.T) {
resolver := NewStandardDNSResolver()
ctx := context.Background()
tests := []struct {
name string
domain string
expectDNSSEC bool
}{
{
name: "ietf.org has DNSSEC",
domain: "ietf.org",
expectDNSSEC: true,
},
{
name: "google.com doesn't have DNSSEC",
domain: "google.com",
expectDNSSEC: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
enabled, err := resolver.IsDNSSECEnabled(ctx, tt.domain)
if err != nil {
t.Errorf("IsDNSSECEnabled() error = %v", err)
return
}
if enabled != tt.expectDNSSEC {
t.Errorf("IsDNSSECEnabled() for %s = %v, want %v", tt.domain, enabled, tt.expectDNSSEC)
} else {
// Log the result even if we're not validating
if enabled {
t.Logf("%s: DNSSEC is enabled ✅", tt.domain)
} else {
t.Logf("%s: DNSSEC is NOT enabled ⚠️", tt.domain)
}
}
})
}
}
func TestIsDNSSECEnabled_NonExistentDomain(t *testing.T) {
resolver := NewStandardDNSResolver()
ctx := context.Background()
// Test with a domain that doesn't exist
enabled, err := resolver.IsDNSSECEnabled(ctx, "this-domain-definitely-does-not-exist-12345.com")
if err != nil {
// Error is acceptable for non-existent domains
t.Logf("Non-existent domain returned error (expected): %v", err)
return
}
// If no error, DNSSEC should be disabled
if enabled {
t.Error("IsDNSSECEnabled() for non-existent domain should return false")
}
}
func TestIsDNSSECEnabled_WithTrailingDot(t *testing.T) {
resolver := NewStandardDNSResolver()
ctx := context.Background()
// Test that both formats work
domain1 := "cloudflare.com"
domain2 := "cloudflare.com."
enabled1, err1 := resolver.IsDNSSECEnabled(ctx, domain1)
if err1 != nil {
t.Errorf("IsDNSSECEnabled() without trailing dot error = %v", err1)
}
enabled2, err2 := resolver.IsDNSSECEnabled(ctx, domain2)
if err2 != nil {
t.Errorf("IsDNSSECEnabled() with trailing dot error = %v", err2)
}
if enabled1 != enabled2 {
t.Errorf("IsDNSSECEnabled() results differ: without dot = %v, with dot = %v", enabled1, enabled2)
}
}

View file

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

View file

@ -256,33 +256,6 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
} }
for _, headerName := range saHeaders { 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 != "" { if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value headers[headerName] = value
} }
@ -328,20 +301,3 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
func (e *EmailMessage) HasHeader(key string) bool { func (e *EmailMessage) HasHeader(key string) bool {
return e.Header.Get(key) != "" 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

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

View file

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

View file

@ -33,10 +33,8 @@ import (
type ReportGenerator struct { type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer dnsAnalyzer *DNSAnalyzer
rblChecker *DNSListChecker rblChecker *RBLChecker
dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer headerAnalyzer *HeaderAnalyzer
} }
@ -46,16 +44,13 @@ func NewReportGenerator(
dnsTimeout time.Duration, dnsTimeout time.Duration,
httpTimeout time.Duration, httpTimeout time.Duration,
rbls []string, rbls []string,
dnswls []string,
checkAllIPs bool, checkAllIPs bool,
) *ReportGenerator { ) *ReportGenerator {
return &ReportGenerator{ return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(), authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(), spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
contentAnalyzer: NewContentAnalyzer(httpTimeout), contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(), headerAnalyzer: NewHeaderAnalyzer(),
} }
@ -68,10 +63,8 @@ type AnalysisResults struct {
Content *ContentResults Content *ContentResults
DNS *api.DNSResults DNS *api.DNSResults
Headers *api.HeaderAnalysis Headers *api.HeaderAnalysis
RBL *DNSListResults RBL *RBLResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
} }
// AnalyzeEmail performs complete email analysis // AnalyzeEmail performs complete email analysis
@ -85,9 +78,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email) results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email) results.Content = r.contentAnalyzer.AnalyzeContent(email)
return results return results
@ -140,29 +131,13 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
blacklistScore := 0 blacklistScore := 0
var blacklistGrade string var blacklistGrade string
if results.RBL != nil { if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL) blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
} }
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) spamScore := 0
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 var spamGrade string
switch { if results.SpamAssassin != nil {
case saGrade == "" && rspamdGrade == "": spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
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{ report.Summary = &api.ScoreSummary{
@ -202,27 +177,9 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
report.Blacklists = &results.RBL.Checks report.Blacklists = &results.RBL.Checks
} }
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only) // Add SpamAssassin result
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 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 // Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" { if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders report.RawHeaders = &results.Email.RawHeaders

View file

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

View file

@ -1,155 +0,0 @@
// 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)
}

View file

@ -1,414 +0,0 @@
// 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

@ -69,31 +69,3 @@ func ScoreToGradeKind(score int) string {
func ScoreToReportGrade(score int) api.ReportGrade { func ScoreToReportGrade(score int) api.ReportGrade {
return api.ReportGrade(ScoreToGrade(score)) 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 // Parse X-Spam-Status header
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" { if statusHeader, ok := headers["X-Spam-Status"]; ok {
a.parseSpamStatus(statusHeader, result) a.parseSpamStatus(statusHeader, result)
} }

1168
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@
"generate:api": "openapi-ts" "generate:api": "openapi-ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.0", "@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.86.10", "@hey-api/openapi-ts": "0.86.10",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.9",
@ -26,7 +26,7 @@
"eslint": "^9.38.0", "eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4", "eslint-plugin-svelte": "^3.12.4",
"globals": "^17.0.0", "globals": "^16.4.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5", "svelte": "^5.39.5",

View file

@ -27,6 +27,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@ -66,10 +67,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig["rbls"] = cfg.Analysis.RBLs appConfig["rbls"] = cfg.Analysis.RBLs
} }
if cfg.CustomLogoURL != "" {
appConfig["custom_logo_url"] = cfg.CustomLogoURL
}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil { if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application") log.Println("Unable to generate JSON config to inject in web application")
} else { } else {
@ -143,7 +140,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
} }
} }
v, _ := io.ReadAll(resp.Body) v, _ := ioutil.ReadAll(resp.Body)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1) v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
@ -170,7 +167,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if indexTpl == nil { if indexTpl == nil {
// Create template from file // Create template from file
f, _ := Assets.Open("index.html") f, _ := Assets.Open("index.html")
v, _ := io.ReadAll(f) v, _ := ioutil.ReadAll(f)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1) v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)

View file

@ -1,9 +1,6 @@
:root { :root {
--bs-primary: #1cb487; --bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135; --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 { body {
@ -11,10 +8,6 @@ body {
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
} }
.bg-tertiary {
background-color: var(--bs-tertiary-bg);
}
/* Animations */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { from {

View file

@ -19,7 +19,6 @@
case "domain_pass": case "domain_pass":
case "orgdomain_pass": case "orgdomain_pass":
return "text-success"; return "text-success";
case "permerror":
case "error": case "error":
case "fail": case "fail":
case "missing": case "missing":
@ -52,7 +51,6 @@
case "neutral": case "neutral":
case "invalid": case "invalid":
case "null": case "null":
case "permerror":
case "error": case "error":
case "null_smtp": case "null_smtp":
case "null_header": case "null_header":
@ -98,442 +96,281 @@
</h4> </h4>
</div> </div>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<!-- IPREV --> <!-- IPREV -->
{#if authentication.iprev} {#if authentication.iprev}
<div class="list-group-item" id="authentication-iprev"> <div class="list-group-item" id="authentication-iprev">
<div class="d-flex align-items-start"> <div class="d-flex align-items-start">
<i <i class="bi {getAuthResultIcon(authentication.iprev.result, true)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"></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>
</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>
<!-- 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> <div>
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""}</strong <strong>IP Reverse DNS</strong>
> <span class="text-uppercase ms-2 {getAuthResultClass(authentication.iprev.result, true)}">
<span {authentication.iprev.result}
class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}"
>
{dkim.result}
</span> </span>
{#if dkim.domain} {#if authentication.iprev.ip}
<div class="small"> <div class="small">
<strong>Domain:</strong> <strong>IP Address:</strong>
<span class="text-muted">{dkim.domain}</span> <span class="text-muted">{authentication.iprev.ip}</span>
</div> </div>
{/if} {/if}
{#if dkim.selector} {#if authentication.iprev.hostname}
<div class="small"> <div class="small">
<strong>Selector:</strong> <strong>Hostname:</strong>
<span class="text-muted">{dkim.selector}</span> <span class="text-muted">{authentication.iprev.hostname}</span>
</div> </div>
{/if} {/if}
{#if dkim.details} {#if authentication.iprev.details}
<pre <pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.iprev.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} {/if}
</div> </div>
</div> </div>
{/each}
{: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>
</div>
</div> </div>
{/if} {/if}
</div>
<!-- X-Google-DKIM (Optional) --> <!-- SPF (Required) -->
{#if authentication.x_google_dkim} <div class="list-group-item">
<div class="list-group-item" id="authentication-x-google-dkim"> <div class="d-flex align-items-start" id="authentication-spf">
<div class="d-flex align-items-start"> {#if authentication.spf}
<i <i class="bi {getAuthResultIcon(authentication.spf.result, true)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"></i>
class="bi {getAuthResultIcon( <div>
authentication.x_google_dkim.result, <strong>SPF</strong>
false, <span class="text-uppercase ms-2 {getAuthResultClass(authentication.spf.result, true)}">
)} {getAuthResultClass( {authentication.spf.result}
authentication.x_google_dkim.result, </span>
false, {#if authentication.spf.domain}
)} me-2 fs-5" <div class="small">
></i> <strong>Domain:</strong>
<div> <span class="text-muted">{authentication.spf.domain}</span>
<strong>X-Google-DKIM</strong> </div>
<i {/if}
class="bi bi-info-circle text-muted ms-1" {#if authentication.spf.details}
title="Google's internal DKIM signature for messages routed through Gmail infrastructure" <pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.spf.details}</pre>
></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}
<!-- 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">
<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}
{#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>
</div> {:else}
{/if} <i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
</div> <div>
</div> <strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
<!-- BIMI (Optional) --> {getAuthResultText('missing')}
<div class="list-group-item" id="authentication-bimi"> </span>
<div class="d-flex align-items-start"> <div class="text-muted small">SPF record is required for proper email authentication</div>
{#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> </div>
{#if authentication.bimi.details} {/if}
<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>
</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}
</div>
</div>
{/each}
{: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>
</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}
<!-- 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">
<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>
</div> </div>

View file

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

View file

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

View file

@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { DnsResults, DomainAlignment, ReceivedHop } from "$lib/api/types.gen"; import type { DomainAlignment, DnsResults, ReceivedHop } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score"; import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import GradeDisplay from "./GradeDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte"; import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import DnssecDisplay from "./DnssecDisplay.svelte";
interface Props { interface Props {
domainAlignment?: DomainAlignment; domainAlignment?: DomainAlignment;
@ -20,14 +21,7 @@
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view) domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
} }
let { let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain, domainOnly = false }: Props = $props();
domainAlignment,
dnsResults,
dnsGrade,
dnsScore,
receivedChain,
domainOnly = false,
}: Props = $props();
// Extract sender IP from first hop // Extract sender IP from first hop
const senderIp = $derived( const senderIp = $derived(
@ -74,10 +68,7 @@
{#if receivedChain && receivedChain.length > 0} {#if receivedChain && receivedChain.length > 0}
<div class="mb-3 d-flex align-items-center gap-2"> <div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate"> <h4 class="mb-0 text-truncate">
Received from: <code Received from: <code>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])</code>
>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0]
.ip}])</code
>
</h4> </h4>
</div> </div>
{/if} {/if}
@ -98,13 +89,10 @@
<div class="mb-3"> <div class="mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<h4 class="mb-0 text-truncate"> <h4 class="mb-0 text-truncate">
Return-Path Domain: Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
<code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
</h4> </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)} {#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"> <span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain</span>
<i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain
</span>
<small> <small>
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
<a href="#domain-alignment">See domain alignment</a> <a href="#domain-alignment">See domain alignment</a>
@ -127,13 +115,10 @@
{/if} {/if}
<!-- SPF Records (for Return-Path Domain) --> <!-- SPF Records (for Return-Path Domain) -->
<SpfRecordsDisplay <SpfRecordsDisplay spfRecords={dnsResults.spf_records} dmarcRecord={dnsResults.dmarc_record} />
spfRecords={dnsResults.spf_records}
dmarcRecord={dnsResults.dmarc_record}
/>
{#if !domainOnly} {#if !domainOnly}
<hr class="my-4" /> <hr class="my-4">
<!-- From Domain Section --> <!-- From Domain Section -->
<div class="mb-3 d-flex align-items-center gap-2"> <div class="mb-3 d-flex align-items-center gap-2">
@ -141,34 +126,34 @@
From Domain: <code>{dnsResults.from_domain}</code> From Domain: <code>{dnsResults.from_domain}</code>
</h4> </h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2"> <span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain</span>
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
domain
</span>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- MX Records for From Domain --> <!-- MX Records for From Domain -->
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
<MxRecordsDisplay <MxRecordsDisplay
class="mb-4" class="mb-4"
mxRecords={dnsResults.from_mx_records} mxRecords={dnsResults.from_mx_records}
title="Mail Exchange Records for From Domain" title="Mail Exchange Records for From Domain"
description="These MX records handle replies to emails sent from this domain." description="These MX records handle replies to emails sent from this domain."
/> />
{/if} {/if}
{#if !domainOnly} {#if !domainOnly}
<!-- DKIM Records --> <!-- DKIM Records -->
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} /> <DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
{/if} {/if}
<!-- DMARC Record --> <!-- DMARC Record -->
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} /> <DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
<!-- BIMI Record --> <!-- BIMI Record -->
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} /> <BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
<!-- DNSSEC -->
<DnssecDisplay dnssecEnabled={dnsResults.dnssec_enabled} domain={dnsResults.from_domain} />
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
dnssecEnabled?: boolean;
domain?: string;
}
let { dnssecEnabled, domain }: Props = $props();
// DNSSEC is valid if it's explicitly enabled
const dnssecIsValid = $derived(dnssecEnabled === true);
</script>
{#if dnssecEnabled !== undefined}
<div class="card mb-4" id="dns-dnssec">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-shield-check={dnssecIsValid}
class:text-success={dnssecIsValid}
class:bi-shield-x={!dnssecIsValid}
class:text-warning={!dnssecIsValid}
></i>
DNSSEC
</h5>
<span class="badge bg-secondary">Security</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-3">
DNSSEC (DNS Security Extensions) adds cryptographic signatures to DNS records to verify
their authenticity and integrity. It protects against DNS spoofing and cache poisoning
attacks, ensuring that DNS responses haven't been tampered with.
</p>
{#if domain}
<div class="mb-2">
<strong>Domain:</strong> <code>{domain}</code>
</div>
{/if}
{#if dnssecIsValid}
<div class="alert alert-success mb-0">
<i class="bi bi-check-circle me-1"></i>
<strong>Enabled:</strong> DNSSEC is properly configured with a valid chain of trust.
This provides additional security and authenticity for your domain's DNS records.
</div>
{:else}
<div class="alert alert-warning mb-0">
<i class="bi bi-info-circle me-1"></i>
<strong>Not Enabled:</strong> DNSSEC is not configured for this domain. While not
required for email delivery, enabling DNSSEC provides additional security by protecting
against DNS-based attacks. Consider enabling DNSSEC through your domain registrar or
DNS provider.
</div>
{/if}
</div>
</div>
{/if}

View file

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

View file

@ -44,7 +44,10 @@
} }
</script> </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} {#if grade}
{grade} {grade}
{:else} {:else}

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen"; import type { AuthResult, DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score"; import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte";
@ -38,14 +38,7 @@
<div class="mb-3"> <div class="mb-3">
<h5>Issues</h5> <h5>Issues</h5>
{#each headerAnalysis.issues as issue} {#each headerAnalysis.issues as issue}
<div <div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
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 class="d-flex justify-content-between align-items-start">
<div> <div>
<strong>{issue.header}</strong> <strong>{issue.header}</strong>
@ -65,48 +58,24 @@
{/if} {/if}
{#if headerAnalysis.domain_alignment} {#if headerAnalysis.domain_alignment}
{@const spfStrictAligned = {@const spfStrictAligned = headerAnalysis.domain_alignment.from_domain === headerAnalysis.domain_alignment.return_path_domain}
headerAnalysis.domain_alignment.from_domain === {@const spfRelaxedAligned = headerAnalysis.domain_alignment.from_org_domain === headerAnalysis.domain_alignment.return_path_org_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 mb-3" id="domain-alignment">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
<i <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>
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 Domain Alignment
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="card-text small text-muted"> <p class="card-text small text-muted">
Domain alignment ensures that the visible "From" domain matches the domain 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.
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> </p>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3"> <div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue} {#each headerAnalysis.domain_alignment.issues as issue}
<div <div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
class="alert alert-{headerAnalysis.domain_alignment <i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
.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} {issue}
</div> </div>
{/each} {/each}
@ -115,10 +84,7 @@
</div> </div>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<div class="list-group-item d-flex ps-0"> <div class="list-group-item d-flex ps-0">
<div <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;">
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 SPF
</div> </div>
<div class="flex-fill"> <div class="flex-fill">
@ -126,17 +92,9 @@
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">Strict Alignment</small> <small class="text-muted">Strict Alignment</small>
<div> <div>
<span <span class="badge" class:bg-success={spfStrictAligned} class:bg-danger={!spfStrictAligned}>
class="badge" <i class="bi {spfStrictAligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
class:bg-success={spfStrictAligned} <strong>{spfStrictAligned ? 'Pass' : 'Fail'}</strong>
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> </span>
</div> </div>
<div class="small text-muted mt-1">Exact domain match</div> <div class="small text-muted mt-1">Exact domain match</div>
@ -144,78 +102,38 @@
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small> <small class="text-muted">Relaxed Alignment</small>
<div> <div>
<span <span class="badge" class:bg-success={spfRelaxedAligned} class:bg-danger={!spfRelaxedAligned}>
class="badge" <i class="bi {spfRelaxedAligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
class:bg-success={spfRelaxedAligned} <strong>{spfRelaxedAligned ? 'Pass' : 'Fail'}</strong>
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> </span>
</div> </div>
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">Organizational domain match</div>
Organizational domain match
</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">From Domain</small> <small class="text-muted">From Domain</small>
<div> <div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></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} {#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"> <div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
Org:
<code>
{headerAnalysis.domain_alignment.from_org_domain}
</code>
</div>
{/if} {/if}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">Return-Path Domain</small> <small class="text-muted">Return-Path Domain</small>
<div> <div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></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} {#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"> <div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
Org:
<code>
{headerAnalysis.domain_alignment
.return_path_org_domain}
</code>
</div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Alignment Information based on DMARC policy --> <!-- 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} {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div <div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === {#if dmarcRecord.spf_alignment === 'strict'}
'strict'
? 'alert-warning'
: 'alert-info'}"
>
{#if dmarcRecord.spf_alignment === "strict"}
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy <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.
requires exact domain match. The Return-Path domain must exactly
match the From domain for SPF to pass DMARC alignment.
{:else} {:else}
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy <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.
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} {/if}
</div> </div>
{/if} {/if}
@ -223,16 +141,10 @@
</div> </div>
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain} {#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
{@const dkim_aligned = {@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
dkim_domain.domain === headerAnalysis.domain_alignment.from_domain} {@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_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="list-group-item d-flex ps-0">
<div <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;">
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 DKIM
</div> </div>
<div class="flex-fill"> <div class="flex-fill">
@ -241,72 +153,35 @@
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">Strict Alignment</small> <small class="text-muted">Strict Alignment</small>
<div> <div>
<span <span class="badge" class:bg-success={dkim_aligned} class:bg-danger={!dkim_aligned}>
class="badge" <i class="bi {dkim_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
class:bg-success={dkim_aligned} <strong>{dkim_aligned ? 'Pass' : 'Fail'}</strong>
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> </span>
</div> </div>
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">Exact domain match</div>
Exact domain match
</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small> <small class="text-muted">Relaxed Alignment</small>
<div> <div>
<span <span class="badge" class:bg-success={dkim_relaxed_aligned} class:bg-danger={!dkim_relaxed_aligned}>
class="badge" <i class="bi {dkim_relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
class:bg-success={dkim_relaxed_aligned} <strong>{dkim_relaxed_aligned ? 'Pass' : 'Fail'}</strong>
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> </span>
</div> </div>
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">Organizational domain match</div>
Organizational domain match
</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">From Domain</small> <small class="text-muted">From Domain</small>
<div> <div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></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} {#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"> <div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
Org: <code
>{headerAnalysis.domain_alignment
.from_org_domain}</code
>
</div>
{/if} {/if}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<small class="text-muted">Signature Domain</small> <small class="text-muted">Signature Domain</small>
<div><code>{dkim_domain.domain || "-"}</code></div> <div><code>{dkim_domain.domain || '-'}</code></div>
{#if dkim_domain.domain !== dkim_domain.org_domain} {#if dkim_domain.domain !== dkim_domain.org_domain}
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">Org: <code>{dkim_domain.org_domain}</code></div>
Org: <code>{dkim_domain.org_domain}</code>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -314,25 +189,13 @@
<!-- Alignment Information based on DMARC policy --> <!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
<div <div class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment === {#if dmarcRecord.dkim_alignment === 'strict'}
'strict'
? 'alert-warning'
: 'alert-info'}"
>
{#if dmarcRecord.dkim_alignment === "strict"}
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict DKIM alignment required</strong> <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.
Your DMARC policy requires exact domain match. The
DKIM signature domain must exactly match the From
domain for DKIM to pass DMARC alignment.
{:else} {:else}
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
<strong>Relaxed DKIM alignment allowed</strong> <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.
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} {/if}
</div> </div>
{/if} {/if}
@ -361,9 +224,9 @@
</thead> </thead>
<tbody> <tbody>
{#each Object.entries(headerAnalysis.headers).sort((a, b) => { {#each Object.entries(headerAnalysis.headers).sort((a, b) => {
const importanceOrder = { required: 0, recommended: 1, optional: 2, newsletter: 3 }; const importanceOrder = { 'required': 0, 'recommended': 1, 'optional': 2, 'newsletter': 3 };
const aImportance = importanceOrder[a[1].importance || "optional"]; const aImportance = importanceOrder[a[1].importance || 'optional'];
const bImportance = importanceOrder[b[1].importance || "optional"]; const bImportance = importanceOrder[b[1].importance || 'optional'];
return aImportance - bImportance; return aImportance - bImportance;
}) as [name, check]} }) as [name, check]}
<tr> <tr>
@ -372,39 +235,23 @@
</td> </td>
<td> <td>
{#if check.importance} {#if check.importance}
<small <small class="text-{check.importance === 'required' ? 'danger' : check.importance === 'recommended' ? 'warning' : 'secondary'}">
class="text-{check.importance === 'required'
? 'danger'
: check.importance === 'recommended'
? 'warning'
: 'secondary'}"
>
{check.importance} {check.importance}
</small> </small>
{/if} {/if}
</td> </td>
<td> <td>
<i <i class="bi {check.present ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'}"></i>
class="bi {check.present
? 'bi-check-circle text-success'
: 'bi-x-circle text-danger'}"
></i>
</td> </td>
<td> <td>
{#if check.present && check.valid !== undefined} {#if check.present && check.valid !== undefined}
<i <i class="bi {check.valid ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'}"></i>
class="bi {check.valid
? 'bi-check-circle text-success'
: 'bi-x-circle text-warning'}"
></i>
{:else} {:else}
- -
{/if} {/if}
</td> </td>
<td> <td>
<small class="text-muted text-truncate" title={check.value} <small class="text-muted text-truncate" title={check.value}>{check.value || '-'}</small>
>{check.value || "-"}</small
>
{#if check.issues && check.issues.length > 0} {#if check.issues && check.issues.length > 0}
{#each check.issues as issue} {#each check.issues as issue}
<div class="text-warning small"> <div class="text-warning small">

View file

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

View file

@ -21,11 +21,6 @@
); );
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0); const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
let showDifferent = $state(false);
const differentCount = $derived(
ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0,
);
</script> </script>
{#if ptrRecords && ptrRecords.length > 0} {#if ptrRecords && ptrRecords.length > 0}
@ -68,31 +63,15 @@
<div class="mb-2"> <div class="mb-2">
<strong>Forward Resolution (A/AAAA):</strong> <strong>Forward Resolution (A/AAAA):</strong>
{#each ptrForwardRecords as ip} {#each ptrForwardRecords as ip}
{#if ip === senderIp || !fcrDnsIsValid || showDifferent} <div class="d-flex gap-2 align-items-center mt-1">
<div class="d-flex gap-2 align-items-center mt-1"> {#if senderIp && ip === senderIp}
{#if senderIp && ip === senderIp} <span class="badge bg-success">Match</span>
<span class="badge bg-success">Match</span> {:else}
{:else} <span class="badge bg-warning">Different</span>
<span class="badge bg-secondary">Different</span> {/if}
{/if} <code>{ip}</code>
<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> </div>
{/if} {/each}
</div> </div>
{#if fcrDnsIsValid} {#if fcrDnsIsValid}
<div class="alert alert-success mb-0 mt-2"> <div class="alert alert-success mb-0 mt-2">

View file

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

View file

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

View file

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

View file

@ -113,7 +113,7 @@
} else if (spfResult === "temperror" || spfResult === "permerror") { } else if (spfResult === "temperror" || spfResult === "permerror") {
segments.push({ segments.push({
text: "encountered an error", text: "encountered an error",
highlight: { color: "danger", bold: true }, highlight: { color: "warning", bold: true },
link: "#authentication-spf", link: "#authentication-spf",
}); });
segments.push({ text: ", check your SPF record configuration" }); segments.push({ text: ", check your SPF record configuration" });
@ -318,9 +318,7 @@
// BIMI // BIMI
const bimiResult = report.authentication?.bimi; const bimiResult = report.authentication?.bimi;
if ( if (
dmarcRecord && (dmarcRecord && dmarcRecord.valid && dmarcRecord.policy != "none") &&
dmarcRecord.valid &&
dmarcRecord.policy != "none" &&
(!bimiResult || bimiResult.result !== "skipped") (!bimiResult || bimiResult.result !== "skipped")
) { ) {
const bimiRecord = report.dns_results?.bimi_record; const bimiRecord = report.dns_results?.bimi_record;
@ -331,7 +329,7 @@
highlight: { color: "good", bold: true }, highlight: { color: "good", bold: true },
link: "#dns-bimi", link: "#dns-bimi",
}); });
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) { if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
segments.push({ text: " declined to participate" }); segments.push({ text: " declined to participate" });
} else if (bimiResult?.result === "fail") { } else if (bimiResult?.result === "fail") {
segments.push({ text: " but " }); segments.push({ text: " but " });
@ -422,17 +420,6 @@
}); });
} }
// 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 // Content/spam assessment
const spamAssassin = report.spamassassin; const spamAssassin = report.spamassassin;
const contentScore = report.summary?.content_score || 0; const contentScore = report.summary?.content_score || 0;
@ -536,39 +523,19 @@
{#if segment.link} {#if segment.link}
<a <a
href={segment.link} href={segment.link}
class="summary-link {segment.highlight class="summary-link {segment.highlight ? getColorClass(segment.highlight.color) : ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
? getColorClass(segment.highlight.color)
: ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight
?.emphasis
? 'fst-italic'
: ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
> >
{segment.text} {segment.text}
</a> </a>
{:else if segment.highlight} {:else if segment.highlight}
<span <span class="{getColorClass(segment.highlight.color)} {segment.highlight.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}">
class="{getColorClass(segment.highlight.color)} {segment.highlight.bold
? 'highlighted'
: ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment
.highlight?.monospace
? 'font-monospace'
: ''}"
>
{segment.text} {segment.text}
</span> </span>
{:else} {:else}
{segment.text} {segment.text}
{/if} {/if}
{/each} {/each}
Overall, your email received a grade <GradeDisplay 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 🔽
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> </p>
{@render children?.()} {@render children?.()}
</div> </div>

View file

@ -1,62 +0,0 @@
<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,27 +1,25 @@
// Component exports // 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 AuthenticationCard } from "./AuthenticationCard.svelte";
export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte"; export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as BlacklistCard } from "./BlacklistCard.svelte"; export { default as BlacklistCard } from "./BlacklistCard.svelte";
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; export { default as ContentAnalysisCard } from "./ContentAnalysisCard.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 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 PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
export { default as ScoreCard } from "./ScoreCard.svelte"; export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.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 TinySurvey } from "./TinySurvey.svelte";
export { default as WhitelistCard } from "./WhitelistCard.svelte"; export { default as ErrorDisplay } from "./ErrorDisplay.svelte";
export { default as GradeDisplay } from "./GradeDisplay.svelte";
export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte";
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte";
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
export { default as Logo } from "./Logo.svelte";
export { default as EmailPathCard } from "./EmailPathCard.svelte";

View file

@ -24,8 +24,6 @@ import { writable } from "svelte/store";
interface AppConfig { interface AppConfig {
report_retention?: number; report_retention?: number;
survey_url?: string; survey_url?: string;
custom_logo_url?: string;
rbls?: string[];
} }
const defaultConfig: AppConfig = { const defaultConfig: AppConfig = {

View file

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

View file

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

View file

@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import "bootstrap-icons/font/bootstrap-icons.css";
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "../app.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 Logo from "$lib/components/Logo.svelte";
import { appConfig } from "$lib/stores/config";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
import { onMount } from "svelte"; import { onMount } from "svelte";
@ -26,19 +25,15 @@
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<div class="min-vh-100 d-flex flex-column"> <div class="min-vh-100 d-flex flex-column">
<nav class="navbar navbar-expand-lg navbar-light shadow-sm"> <nav class="navbar navbar-expand-lg navbar-light shadow-sm">
<div class="container"> <div class="container">
<a class="navbar-brand fw-bold" href="/"> <a class="navbar-brand fw-bold" href="/">
{#if $appConfig.custom_logo_url} <i class="bi bi-envelope-check me-2"></i>
<img src={$appConfig.custom_logo_url} alt="Logo" style="height: 25px;" /> <Logo color={$theme === "light" ? "black" : "white"} />
{:else}
<i class="bi bi-envelope-check me-2"></i>
<Logo color={$theme === "light" ? "black" : "white"} />
{/if}
</a> </a>
<div> <div>
<span class="d-none d-md-inline navbar-text text-primary small"> <span class="d-none d-md-inline navbar-text text-primary small">
@ -60,26 +55,7 @@
{@render children?.()} {@render children?.()}
</main> </main>
<footer <footer class="pt-3 pb-2 bg-dark text-light">
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="container mb-4">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-4">
<div class="col"> <div class="col">
@ -168,27 +144,6 @@
border-top: 3px solid #9332bb; 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 { .footer-links {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -200,6 +155,7 @@
.footer-links a { .footer-links a {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
text-decoration: none;
transition: color 0.3s; transition: color 0.3s;
} }

View file

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { createTest as apiCreateTest } from "$lib/api"; import { createTest as apiCreateTest } from "$lib/api";
import { FeatureCard, HowItWorksStep } from "$lib/components";
import { appConfig } from "$lib/stores/config"; import { appConfig } from "$lib/stores/config";
import { FeatureCard, HowItWorksStep } from "$lib/components";
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);

View file

@ -15,10 +15,8 @@
} }
// Basic IPv4/IPv6 validation // Basic IPv4/IPv6 validation
const ipv4Pattern = 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]?)$/;
/^(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]))$/;
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())) { if (!ipv4Pattern.test(ip.trim()) && !ipv6Pattern.test(ip.trim())) {
error = "Please enter a valid IPv4 or IPv6 address (e.g., 192.0.2.1)"; error = "Please enter a valid IPv4 or IPv6 address (e.g., 192.0.2.1)";
@ -50,8 +48,7 @@
Check IP Blacklist Status Check IP Blacklist Status
</h1> </h1>
<p class="lead text-muted"> <p class="lead text-muted">
Test an IP address against multiple DNS-based blacklists (RBLs) to check its Test an IP address against multiple DNS-based blacklists (RBLs) to check its reputation.
reputation.
</p> </p>
</div> </div>
@ -106,9 +103,7 @@
</h3> </h3>
<ul class="list-unstyled mb-0 small"> <ul class="list-unstyled mb-0 small">
{#each $appConfig.rbls as rbl} {#each $appConfig.rbls as rbl}
<li class="mb-2"> <li class="mb-2"><i class="bi bi-arrow-right me-2"></i>{rbl}</li>
<i class="bi bi-arrow-right me-2"></i>{rbl}
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -123,9 +118,7 @@
Why Check Blacklists? Why Check Blacklists?
</h3> </h3>
<p class="small mb-2"> <p class="small mb-2">
DNS-based blacklists (RBLs) are used by email servers to identify DNS-based blacklists (RBLs) are used by email servers to identify and block spam sources. Being listed can severely impact email deliverability.
and block spam sources. Being listed can severely impact email
deliverability.
</p> </p>
<p class="small mb-3"> <p class="small mb-3">
This tool checks your IP against multiple popular RBLs to help you: This tool checks your IP against multiple popular RBLs to help you:
@ -135,8 +128,7 @@
<i class="bi bi-arrow-right me-2"></i>Monitor IP reputation <i class="bi bi-arrow-right me-2"></i>Monitor IP reputation
</li> </li>
<li class="mb-1"> <li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Identify deliverability <i class="bi bi-arrow-right me-2"></i>Identify deliverability issues
issues
</li> </li>
<li class="mb-1"> <li class="mb-1">
<i class="bi bi-arrow-right me-2"></i>Take corrective action <i class="bi bi-arrow-right me-2"></i>Take corrective action
@ -154,8 +146,7 @@
Need Complete Email Analysis? Need Complete Email Analysis?
</h3> </h3>
<p class="small mb-2"> <p class="small mb-2">
For comprehensive deliverability testing including DKIM verification, content For comprehensive deliverability testing including DKIM verification, content analysis, spam scoring, and more:
analysis, spam scoring, and more:
</p> </p>
<a href="/" class="btn btn-sm btn-outline-primary"> <a href="/" class="btn btn-sm btn-outline-primary">
<i class="bi bi-envelope-plus me-1"></i> <i class="bi bi-envelope-plus me-1"></i>
@ -168,9 +159,7 @@
<style> <style>
.card { .card {
transition: transition: transform 0.2s ease, box-shadow 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
} }
.card:hover { .card:hover {

View file

@ -3,7 +3,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { checkBlacklist } from "$lib/api"; import { checkBlacklist } from "$lib/api";
import type { BlacklistCheckResponse } from "$lib/api/types.gen"; import type { BlacklistCheckResponse } from "$lib/api/types.gen";
import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components"; import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
let ip = $derived($page.params.ip); let ip = $derived($page.params.ip);
@ -28,7 +28,7 @@
}); });
if (response.response.ok) { if (response.response.ok) {
result = response.data ?? null; result = response.data;
} else if (response.error) { } else if (response.error) {
error = response.error.message || "Failed to check IP address"; error = response.error.message || "Failed to check IP address";
} }
@ -80,8 +80,7 @@
<!-- Error State --> <!-- Error State -->
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center py-5"> <div class="card-body text-center py-5">
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;" <i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
></i>
<h3 class="h4 mt-4">Check Failed</h3> <h3 class="h4 mt-4">Check Failed</h3>
<p class="text-muted mb-4">{error}</p> <p class="text-muted mb-4">{error}</p>
<button class="btn btn-primary" onclick={analyzeIP}> <button class="btn btn-primary" onclick={analyzeIP}>
@ -99,33 +98,22 @@
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0"> <div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
<h2 class="h2 mb-2"> <h2 class="h2 mb-2">
<span class="font-monospace text-truncate">{result.ip}</span <span class="font-monospace text-truncate">{result.ip}</span>
>
</h2> </h2>
{#if result.listed_count === 0} {#if result.listed_count === 0}
<div class="alert alert-success mb-0 d-inline-block"> <div class="alert alert-success mb-0 d-inline-block">
<i class="bi bi-check-circle me-2"></i> <i class="bi bi-check-circle me-2"></i>
<strong>Not Listed</strong> <strong>Not Listed</strong>
<p class="d-inline mb-0 mt-1 small"> <p class="d-inline mb-0 mt-1 small">
This IP address is not listed on any checked This IP address is not listed on any checked blacklists.
blacklists.
</p> </p>
</div> </div>
{:else} {:else}
<div class="alert alert-danger mb-0 d-inline-block"> <div class="alert alert-danger mb-0 d-inline-block">
<i class="bi bi-exclamation-triangle me-2"></i> <i class="bi bi-exclamation-triangle me-2"></i>
<strong <strong>Listed on {result.listed_count} blacklist{result.listed_count > 1 ? "s" : ""}</strong>
>Listed on {result.listed_count} blacklist{result.listed_count >
1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small"> <p class="mb-0 mt-1 small">
This IP address is listed on {result.listed_count} of This IP address is listed on {result.listed_count} of {result.checks.length} checked blacklist{result.checks.length > 1 ? "s" : ""}.
{result.blacklists.length} checked blacklist{result
.blacklists.length > 1
? "s"
: ""}.
</p> </p>
</div> </div>
{/if} {/if}
@ -133,8 +121,8 @@
<div class="offset-md-3 col-md-3 text-center"> <div class="offset-md-3 col-md-3 text-center">
<div <div
class="p-2 rounded text-center summary-card" class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"} class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== "light"} class:bg-secondary={$theme !== 'light'}
> >
<GradeDisplay score={result.score} grade={result.grade} /> <GradeDisplay score={result.score} grade={result.grade} />
<small class="text-muted d-block">Blacklist Score</small> <small class="text-muted d-block">Blacklist Score</small>
@ -150,23 +138,12 @@
</div> </div>
</div> </div>
<div class="row"> <!-- Blacklist Results Card -->
<!-- Blacklist Results Card --> <BlacklistCard
<div class="col col-lg-6"> blacklists={{ [result.ip]: result.checks }}
<BlacklistCard blacklistScore={result.score}
blacklists={{ [result.ip]: result.blacklists }} blacklistGrade={result.grade}
blacklistScore={result.score} />
blacklistGrade={result.grade}
/>
</div>
<!-- Whitelist Results Card -->
{#if result.whitelists && result.whitelists.length > 0}
<div class="col col-lg-6">
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
</div>
{/if}
</div>
<!-- Information Card --> <!-- Information Card -->
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">
@ -177,36 +154,23 @@
</h3> </h3>
{#if result.listed_count === 0} {#if result.listed_count === 0}
<p class="mb-3"> <p class="mb-3">
<strong>Good news!</strong> This IP address is not currently listed <strong>Good news!</strong> This IP address is not currently listed on any of the
on any of the checked DNS-based blacklists (RBLs). This indicates checked DNS-based blacklists (RBLs). This indicates a good sender reputation
a good sender reputation and should not negatively impact email deliverability. and should not negatively impact email deliverability.
</p> </p>
{:else} {:else}
<p class="mb-3"> <p class="mb-3">
<strong>Warning:</strong> This IP address is listed on {result.listed_count} <strong>Warning:</strong> This IP address is listed on {result.listed_count} blacklist{result.listed_count > 1 ? "s" : ""}.
blacklist{result.listed_count > 1 ? "s" : ""}. Being listed can Being listed can significantly impact email deliverability as many mail servers
significantly impact email deliverability as many mail servers
use these blacklists to filter incoming mail. use these blacklists to filter incoming mail.
</p> </p>
<div class="alert alert-warning"> <div class="alert alert-warning">
<h4 class="h6 mb-2">Recommended Actions:</h4> <h4 class="h6 mb-2">Recommended Actions:</h4>
<ul class="mb-0 small"> <ul class="mb-0 small">
<li> <li>Investigate the cause of the listing (compromised system, spam complaints, etc.)</li>
Investigate the cause of the listing (compromised <li>Fix any security issues or stop sending practices that led to the listing</li>
system, spam complaints, etc.) <li>Request delisting from each RBL (check their websites for removal procedures)</li>
</li> <li>Monitor your IP reputation regularly to prevent future listings</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> </ul>
</div> </div>
{/if} {/if}
@ -222,8 +186,8 @@
</h3> </h3>
<p class="mb-3"> <p class="mb-3">
This blacklist check tests IP reputation only. For comprehensive This blacklist check tests IP reputation only. For comprehensive
deliverability testing including DKIM verification, content deliverability testing including DKIM verification, content analysis,
analysis, spam scoring, and DNS configuration: spam scoring, and DNS configuration:
</p> </p>
<a href="/" class="btn btn-primary"> <a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i> <i class="bi bi-envelope-plus me-2"></i>

View file

@ -13,8 +13,7 @@
} }
// Basic domain validation // Basic domain validation
const domainPattern = 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]?)*$/;
/^[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())) { if (!domainPattern.test(domain.trim())) {
error = "Please enter a valid domain name (e.g., example.com)"; error = "Please enter a valid domain name (e.g., example.com)";
return; return;
@ -100,18 +99,10 @@
What's Checked What's Checked
</h3> </h3>
<ul class="list-unstyled mb-0 small"> <ul class="list-unstyled mb-0 small">
<li class="mb-2"> <li class="mb-2"><i class="bi bi-arrow-right me-2"></i>MX Records</li>
<i class="bi bi-arrow-right me-2"></i>MX Records <li class="mb-2"><i class="bi bi-arrow-right me-2"></i>SPF Records</li>
</li> <li class="mb-2"><i class="bi bi-arrow-right me-2"></i>DMARC Policy</li>
<li class="mb-2"> <li class="mb-2"><i class="bi bi-arrow-right me-2"></i>BIMI Support</li>
<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"> <li class="mb-0">
<i class="bi bi-arrow-right me-2"></i>Disposable Domain Check <i class="bi bi-arrow-right me-2"></i>Disposable Domain Check
</li> </li>
@ -158,9 +149,7 @@
<style> <style>
.card { .card {
transition: transition: transform 0.2s ease, box-shadow 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
} }
.card:hover { .card:hover {

View file

@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/stores";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { testDomain } from "$lib/api"; import { testDomain } from "$lib/api";
import type { DomainTestResponse } from "$lib/api/types.gen"; import type { DomainTestResponse } from "$lib/api/types.gen";
import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components"; import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
let domain = $derived(page.params.domain); let domain = $derived($page.params.domain);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let result = $state<DomainTestResponse | null>(null); let result = $state<DomainTestResponse | null>(null);
@ -81,8 +80,7 @@
<!-- Error State --> <!-- Error State -->
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center py-5"> <div class="card-body text-center py-5">
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;" <i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
></i>
<h3 class="h4 mt-4">Analysis Failed</h3> <h3 class="h4 mt-4">Analysis Failed</h3>
<p class="text-muted mb-4">{error}</p> <p class="text-muted mb-4">{error}</p>
<button class="btn btn-primary" onclick={analyzeDomain}> <button class="btn btn-primary" onclick={analyzeDomain}>
@ -107,9 +105,8 @@
<i class="bi bi-exclamation-triangle me-2"></i> <i class="bi bi-exclamation-triangle me-2"></i>
<strong>Disposable Email Provider Detected</strong> <strong>Disposable Email Provider Detected</strong>
<p class="mb-0 mt-1 small"> <p class="mb-0 mt-1 small">
This domain is a known temporary/disposable email This domain is a known temporary/disposable email service.
service. Emails from this domain may have lower Emails from this domain may have lower deliverability.
deliverability.
</p> </p>
</div> </div>
{:else} {:else}
@ -119,8 +116,8 @@
<div class="offset-md-3 col-md-3 text-center"> <div class="offset-md-3 col-md-3 text-center">
<div <div
class="p-2 rounded text-center summary-card" class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"} class:bg-light={$theme === 'light'}
class:bg-secondary={$theme !== "light"} class:bg-secondary={$theme !== 'light'}
> >
<GradeDisplay score={result.score} grade={result.grade} /> <GradeDisplay score={result.score} grade={result.grade} />
<small class="text-muted d-block">DNS</small> <small class="text-muted d-block">DNS</small>
@ -130,7 +127,7 @@
<div class="d-flex justify-content-end me-lg-5 mt-3"> <div class="d-flex justify-content-end me-lg-5 mt-3">
<TinySurvey <TinySurvey
class="bg-primary-subtle rounded-4 p-3 text-center" class="bg-primary-subtle rounded-4 p-3 text-center"
source={"domain-" + result.domain} source={"rbl-" + result.ip}
/> />
</div> </div>
</div> </div>
@ -153,8 +150,8 @@
</h3> </h3>
<p class="mb-3"> <p class="mb-3">
This domain-only test checks DNS configuration. For comprehensive This domain-only test checks DNS configuration. For comprehensive
deliverability testing including DKIM verification, content deliverability testing including DKIM verification, content analysis,
analysis, spam scoring, and blacklist checks: spam scoring, and blacklist checks:
</p> </p>
<a href="/" class="btn btn-primary"> <a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i> <i class="bi bi-envelope-plus me-2"></i>

View file

@ -1,28 +1,22 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state";
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { page } from "$app/state";
import { getReport, getTest, reanalyzeReport } from "$lib/api"; import { getTest, getReport, reanalyzeReport } from "$lib/api";
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen"; import type { Test, Report } from "$lib/api/types.gen";
import { import {
ScoreCard,
SummaryCard,
SpamAssassinCard,
PendingState,
AuthenticationCard, AuthenticationCard,
DnsRecordsCard,
BlacklistCard, BlacklistCard,
ContentAnalysisCard, ContentAnalysisCard,
DnsRecordsCard,
EmailPathCard,
ErrorDisplay,
HeaderAnalysisCard, HeaderAnalysisCard,
PendingState,
RspamdCard,
ScoreCard,
SpamAssassinCard,
SummaryCard,
TinySurvey, TinySurvey,
WhitelistCard, ErrorDisplay,
} from "$lib/components"; } from "$lib/components";
type BlacklistRecords = Record<string, BlacklistCheck[]>;
let testId = $derived(page.params.test); let testId = $derived(page.params.test);
let test = $state<Test | null>(null); let test = $state<Test | null>(null);
let report = $state<Report | null>(null); let report = $state<Report | null>(null);
@ -194,13 +188,7 @@
</script> </script>
<svelte:head> <svelte:head>
<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>
{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> </svelte:head>
<div class="container py-5"> <div class="container py-5">
@ -295,15 +283,6 @@
</div> </div>
</div> </div>
<!-- Received Chain -->
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
<div class="row mb-4" id="received-chain">
<div class="col-12">
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
</div>
</div>
{/if}
<!-- DNS Records --> <!-- DNS Records -->
{#if report.dns_results} {#if report.dns_results}
<div class="row mb-4" id="dns"> <div class="row mb-4" id="dns">
@ -334,45 +313,17 @@
{/if} {/if}
<!-- Blacklist Checks --> <!-- Blacklist Checks -->
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} {#if report.blacklists && Object.keys(report.blacklists).length > 0}
<BlacklistCard <div class="row mb-4" id="blacklist">
{blacklists} <div class="col-12">
blacklistGrade={report.summary?.blacklist_grade} <BlacklistCard
blacklistScore={report.summary?.blacklist_score} blacklists={report.blacklists}
/> blacklistGrade={report.summary?.blacklist_grade}
{/snippet} blacklistScore={report.summary?.blacklist_score}
receivedChain={report.header_analysis?.received_chain}
<!-- Whitelist Checks --> />
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
<WhitelistCard {whitelists} />
{/snippet}
<!-- Blacklist & Whitelist Checks -->
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
<div class="row mb-4">
<div class="col-6" id="blacklist">
{@render blacklistChecks(report.blacklists, report)}
</div>
<div class="col-6" id="whitelist">
{@render whitelistChecks(report.whitelists)}
</div> </div>
</div> </div>
{:else}
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
<div class="row mb-4" id="blacklist">
<div class="col-12">
{@render blacklistChecks(report.blacklists, report)}
</div>
</div>
{/if}
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
<div class="row mb-4" id="whitelist">
<div class="col-12">
{@render whitelistChecks(report.whitelists)}
</div>
</div>
{/if}
{/if} {/if}
<!-- Header Analysis --> <!-- Header Analysis -->
@ -389,19 +340,16 @@
</div> </div>
{/if} {/if}
<!-- Spam filter analysis --> <!-- Additional Information -->
{#if report.spamassassin || report.rspamd} {#if report.spamassassin}
<div class="row mb-4" id="spam"> <div class="row mb-4" id="spam">
{#if report.spamassassin} <div class="col-12">
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}> <SpamAssassinCard
<SpamAssassinCard spamassassin={report.spamassassin} /> spamassassin={report.spamassassin}
</div> spamGrade={report.summary?.spam_grade}
{/if} spamScore={report.summary?.spam_score}
{#if report.rspamd} />
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}> </div>
<RspamdCard rspamd={report.rspamd} />
</div>
{/if}
</div> </div>
{/if} {/if}