Compare commits
53 commits
internal_r
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b6154c183 | |||
| 56e6494a75 | |||
| 0176c3803d | |||
| 21e16fd847 | |||
| edfe498b27 | |||
| 27650a3496 | |||
| d9b9ea87c6 | |||
| bb47bb7c29 | |||
| da93d6d706 | |||
| 2a2bfe46a8 | |||
| 55e9bcd3d0 | |||
| 28424729a5 | |||
| 3cc39c9c54 | |||
| f9c5c815d1 | |||
| 4245f93ce4 | |||
| 9679b381c7 | |||
| 7b9c45fb68 | |||
| b619ebf8c3 | |||
| a146940a65 | |||
| e811d02b3b | |||
| 8fda7746a1 | |||
| 96e83ff70d | |||
| 6b983f0506 | |||
| c50e18a347 | |||
| 054cd8ae25 | |||
| c2917f8580 | |||
| b39a9dc625 | |||
| 88553cd3c8 | |||
| 8a10eef2f5 | |||
| 64ba6932f7 | |||
| 5453c09420 | |||
| 6b4ca126b0 | |||
| ac9b567025 | |||
| 035e864de4 | |||
| a6efd7710e | |||
| e6746a1382 | |||
| d1e48b9885 | |||
| 9ac3e165fa | |||
| dc21b72f52 | |||
| 1ba35c6f9f | |||
| 0fda0f88c1 | |||
| 57a3774d28 | |||
| 11d46de033 | |||
| 6081e486bf | |||
| 528a65ca04 | |||
| 926796b79e | |||
| 5d02070100 | |||
| 5701070cc1 | |||
| 954cbe29fc | |||
| ca2ac3df7c | |||
| 016ed7180e | |||
| 3e76692448 | |||
| e23afcc77c |
61 changed files with 3194 additions and 1397 deletions
22
Dockerfile
22
Dockerfile
|
|
@ -34,7 +34,7 @@ RUN go generate ./... && \
|
|||
# Stage 3: Prepare perl and spamass-milt
|
||||
FROM alpine:3 AS pl
|
||||
|
||||
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
|
||||
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
|
||||
apk add --no-cache \
|
||||
build-base \
|
||||
libmilter-dev \
|
||||
|
|
@ -55,7 +55,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a
|
|||
perl-json-xs \
|
||||
perl-list-moreutils \
|
||||
perl-moose \
|
||||
perl-net-idn-encode@testing \
|
||||
perl-net-idn-encode@edge \
|
||||
perl-net-ssleay \
|
||||
perl-netaddr-ip \
|
||||
perl-package-stash \
|
||||
|
|
@ -86,7 +86,7 @@ RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milt
|
|||
FROM alpine:3
|
||||
|
||||
# Install all required packages
|
||||
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
|
||||
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
|
||||
apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
|
|
@ -106,7 +106,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a
|
|||
perl-json-xs \
|
||||
perl-list-moreutils \
|
||||
perl-moose \
|
||||
perl-net-idn-encode@testing \
|
||||
perl-net-idn-encode@edge \
|
||||
perl-net-ssleay \
|
||||
perl-netaddr-ip \
|
||||
perl-package-stash \
|
||||
|
|
@ -121,6 +121,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a
|
|||
perl-xml-libxml \
|
||||
postfix \
|
||||
postfix-pcre \
|
||||
rspamd \
|
||||
spamassassin \
|
||||
spamassassin-client \
|
||||
supervisor \
|
||||
|
|
@ -143,8 +144,11 @@ RUN mkdir -p /etc/happydeliver \
|
|||
/var/lib/authentication_milter \
|
||||
/var/spool/postfix/authentication_milter \
|
||||
/var/spool/postfix/spamassassin \
|
||||
/var/spool/postfix/rspamd \
|
||||
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
||||
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin
|
||||
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \
|
||||
&& chown rspamd:mail /var/spool/postfix/rspamd \
|
||||
&& chmod 750 /var/spool/postfix/rspamd
|
||||
|
||||
# Copy the built application
|
||||
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||
|
|
@ -154,6 +158,7 @@ RUN chmod +x /usr/local/bin/happyDeliver
|
|||
COPY docker/postfix/ /etc/postfix/
|
||||
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
|
||||
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
||||
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
|
||||
COPY docker/supervisor/ /etc/supervisor/
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
|
@ -165,7 +170,12 @@ RUN chmod +x /entrypoint.sh
|
|||
EXPOSE 25 8080
|
||||
|
||||
# Default configuration
|
||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
|
||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
||||
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
|
||||
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
||||
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
||||
HAPPYDELIVER_DNS_TIMEOUT=5s \
|
||||
HAPPYDELIVER_HTTP_TIMEOUT=10s
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a
|
|||
|
||||
## Features
|
||||
|
||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
|
||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
|
||||
|
|
@ -26,6 +26,7 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha
|
|||
- **Postfix MTA**: Receives emails on port 25
|
||||
- **authentication_milter**: Entreprise grade email authentication
|
||||
- **SpamAssassin**: Spam scoring and analysis
|
||||
- **rspamd**: Second spam filter for cross-validated scoring
|
||||
- **happyDeliver API**: REST API server on port 8080
|
||||
- **SQLite Database**: Persistent storage for tests and reports
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
|
|||
cd happydeliver
|
||||
|
||||
# Edit docker-compose.yml to set your domain
|
||||
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
|
||||
# Change HAPPYDELIVER_DOMAIN environment variable and hostname
|
||||
|
||||
# Build and start
|
||||
docker-compose up -d
|
||||
|
|
@ -63,13 +64,54 @@ docker run -d \
|
|||
-p 25:25 \
|
||||
-p 8080:8080 \
|
||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||
-e HOSTNAME=mail.yourdomain.com \
|
||||
--hostname mail.yourdomain.com \
|
||||
-v $(pwd)/data:/var/lib/happydeliver \
|
||||
-v $(pwd)/logs:/var/log/happydeliver \
|
||||
happydeliver:latest
|
||||
```
|
||||
|
||||
#### 3. Configure Network and DNS
|
||||
#### 3. Configure TLS Certificates (Optional but Recommended)
|
||||
|
||||
To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments.
|
||||
|
||||
##### Using docker-compose
|
||||
|
||||
Add the certificate paths to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt
|
||||
- POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key
|
||||
volumes:
|
||||
- /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro
|
||||
- /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro
|
||||
```
|
||||
|
||||
##### Using docker run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name happydeliver \
|
||||
-p 25:25 \
|
||||
-p 8080:8080 \
|
||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||
-e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \
|
||||
-e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \
|
||||
--hostname mail.yourdomain.com \
|
||||
-v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \
|
||||
-v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \
|
||||
-v $(pwd)/data:/var/lib/happydeliver \
|
||||
-v $(pwd)/logs:/var/log/happydeliver \
|
||||
happydeliver:latest
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The certificate file should contain the full certificate chain (certificate + intermediate CAs)
|
||||
- The private key file must be readable by the postfix user inside the container
|
||||
- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required)
|
||||
- If both environment variables are not set, Postfix will run without TLS support
|
||||
|
||||
#### 4. Configure Network and DNS
|
||||
|
||||
##### Open SMTP Port
|
||||
|
||||
|
|
@ -121,7 +163,7 @@ The server will start on `http://localhost:8080` by default.
|
|||
|
||||
#### 3. Integrate with your existing e-mail setup
|
||||
|
||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
|
||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
||||
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||
|
||||
Choose one of the following way to integrate happyDeliver in your existing setup:
|
||||
|
|
@ -228,7 +270,7 @@ The deliverability score is calculated from A to F based on:
|
|||
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
|
||||
- **Blacklist**: RBL/DNSBL checks
|
||||
- **Headers**: Required headers, MIME structure, Domain alignment
|
||||
- **Spam**: SpamAssassin score
|
||||
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
|
||||
- **Content**: HTML quality, links, images, unsubscribe
|
||||
|
||||
## Funding
|
||||
|
|
|
|||
114
api/openapi.yaml
114
api/openapi.yaml
|
|
@ -333,6 +333,8 @@ components:
|
|||
$ref: '#/components/schemas/AuthenticationResults'
|
||||
spamassassin:
|
||||
$ref: '#/components/schemas/SpamAssassinResult'
|
||||
rspamd:
|
||||
$ref: '#/components/schemas/RspamdResult'
|
||||
dns_results:
|
||||
$ref: '#/components/schemas/DNSResults'
|
||||
blacklists:
|
||||
|
|
@ -348,6 +350,19 @@ components:
|
|||
listed: false
|
||||
- rbl: "bl.spamcop.net"
|
||||
listed: false
|
||||
whitelists:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlacklistCheck'
|
||||
description: Map of IP addresses to their DNS whitelist check results (informational only)
|
||||
example:
|
||||
"192.0.2.1":
|
||||
- rbl: "list.dnswl.org"
|
||||
listed: false
|
||||
- rbl: "swl.spamhaus.org"
|
||||
listed: false
|
||||
content_analysis:
|
||||
$ref: '#/components/schemas/ContentAnalysis'
|
||||
header_analysis:
|
||||
|
|
@ -401,7 +416,7 @@ components:
|
|||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: SpamAssassin score (in percentage)
|
||||
description: Spam filter score (SpamAssassin + rspamd combined, in percentage)
|
||||
example: 15
|
||||
spam_grade:
|
||||
type: string
|
||||
|
|
@ -774,7 +789,7 @@ components:
|
|||
properties:
|
||||
result:
|
||||
type: string
|
||||
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass]
|
||||
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
|
||||
description: Authentication result
|
||||
example: "pass"
|
||||
domain:
|
||||
|
|
@ -843,6 +858,17 @@ components:
|
|||
- is_spam
|
||||
- test_details
|
||||
properties:
|
||||
deliverability_score:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: SpamAssassin deliverability score (0-100, higher is better)
|
||||
example: 80
|
||||
deliverability_grade:
|
||||
type: string
|
||||
enum: [A+, A, B, C, D, E, F]
|
||||
description: Letter grade for SpamAssassin deliverability score
|
||||
example: "B"
|
||||
version:
|
||||
type: string
|
||||
description: SpamAssassin version
|
||||
|
|
@ -905,6 +931,81 @@ components:
|
|||
description: Human-readable description of what this test checks
|
||||
example: "Bayes spam probability is 0 to 1%"
|
||||
|
||||
RspamdResult:
|
||||
type: object
|
||||
required:
|
||||
- score
|
||||
- threshold
|
||||
- is_spam
|
||||
- symbols
|
||||
properties:
|
||||
deliverability_score:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: rspamd deliverability score (0-100, higher is better)
|
||||
example: 85
|
||||
deliverability_grade:
|
||||
type: string
|
||||
enum: [A+, A, B, C, D, E, F]
|
||||
description: Letter grade for rspamd deliverability score
|
||||
example: "A"
|
||||
score:
|
||||
type: number
|
||||
format: float
|
||||
description: rspamd spam score
|
||||
example: -3.91
|
||||
threshold:
|
||||
type: number
|
||||
format: float
|
||||
description: Score threshold for spam classification
|
||||
example: 15.0
|
||||
action:
|
||||
type: string
|
||||
description: rspamd action (no action, add header, rewrite subject, soft reject, reject)
|
||||
example: "no action"
|
||||
is_spam:
|
||||
type: boolean
|
||||
description: Whether message is classified as spam (action is reject or soft reject)
|
||||
example: false
|
||||
server:
|
||||
type: string
|
||||
description: rspamd server that processed the message
|
||||
example: "rspamd.example.com"
|
||||
symbols:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/RspamdSymbol'
|
||||
description: Map of triggered rspamd symbols to their details
|
||||
example:
|
||||
BAYES_HAM:
|
||||
name: "BAYES_HAM"
|
||||
score: -1.9
|
||||
params: "0.02"
|
||||
report:
|
||||
type: string
|
||||
description: Full rspamd report (raw X-Spamd-Result header)
|
||||
|
||||
RspamdSymbol:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- score
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Symbol name
|
||||
example: "BAYES_HAM"
|
||||
score:
|
||||
type: number
|
||||
format: float
|
||||
description: Score contribution of this symbol
|
||||
example: -1.9
|
||||
params:
|
||||
type: string
|
||||
description: Symbol parameters or options
|
||||
example: "0.02"
|
||||
|
||||
DNSResults:
|
||||
type: object
|
||||
required:
|
||||
|
|
@ -1245,7 +1346,7 @@ components:
|
|||
type: object
|
||||
required:
|
||||
- ip
|
||||
- checks
|
||||
- blacklists
|
||||
- listed_count
|
||||
- score
|
||||
- grade
|
||||
|
|
@ -1254,7 +1355,7 @@ components:
|
|||
type: string
|
||||
description: The IP address that was checked
|
||||
example: "192.0.2.1"
|
||||
checks:
|
||||
blacklists:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlacklistCheck'
|
||||
|
|
@ -1274,3 +1375,8 @@ components:
|
|||
enum: [A+, A, B, C, D, E, F]
|
||||
description: Letter grade representation of the score
|
||||
example: "A+"
|
||||
whitelists:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlacklistCheck'
|
||||
description: List of DNS whitelist check results (informational only)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
image: happydomain/happydeliver:latest
|
||||
container_name: happydeliver
|
||||
# Set a hostname
|
||||
hostname: mail.happydeliver.local
|
||||
|
||||
environment:
|
||||
# Set your domain and hostname
|
||||
DOMAIN: happydeliver.local
|
||||
HOSTNAME: mail.happydeliver.local
|
||||
# Set your domain
|
||||
HAPPYDELIVER_DOMAIN: happydeliver.local
|
||||
|
||||
ports:
|
||||
# SMTP port
|
||||
|
|
|
|||
|
|
@ -109,12 +109,13 @@ Default configuration for the Docker environment:
|
|||
|
||||
The container accepts these environment variables:
|
||||
|
||||
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
|
||||
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||
|
||||
Note that the hostname of the container is used to filter the authentication tests results.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
|
||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ set -e
|
|||
echo "Starting happyDeliver container..."
|
||||
|
||||
# Get environment variables with defaults
|
||||
HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
|
||||
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
|
||||
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
|
||||
|
||||
echo "Hostname: $HOSTNAME"
|
||||
|
|
@ -15,6 +15,10 @@ mkdir -p /var/spool/postfix/authentication_milter
|
|||
chown mail:mail /var/spool/postfix/authentication_milter
|
||||
chmod 750 /var/spool/postfix/authentication_milter
|
||||
|
||||
mkdir -p /var/spool/postfix/rspamd
|
||||
chown rspamd:mail /var/spool/postfix/rspamd
|
||||
chmod 750 /var/spool/postfix/rspamd
|
||||
|
||||
# Create log directory
|
||||
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
|
||||
chown happydeliver:happydeliver /var/log/happydeliver
|
||||
|
|
@ -25,6 +29,15 @@ echo "Configuring Postfix..."
|
|||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
|
||||
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
|
||||
|
||||
# Add certificates to postfix
|
||||
[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && {
|
||||
cat <<EOF >> /etc/postfix/main.cf
|
||||
smtpd_tls_cert_file = ${POSTFIX_CERT_FILE}
|
||||
smtpd_tls_key_file = ${POSTFIX_KEY_FILE}
|
||||
smtpd_tls_security_level = may
|
||||
EOF
|
||||
}
|
||||
|
||||
# Replace placeholders in configurations
|
||||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ transport_maps = pcre:/etc/postfix/transport_maps
|
|||
# OpenDKIM for DKIM verification
|
||||
milter_default_action = accept
|
||||
milter_protocol = 6
|
||||
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock
|
||||
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
|
||||
non_smtpd_milters = $smtpd_milters
|
||||
|
||||
# SPF policy checking
|
||||
|
|
|
|||
5
docker/rspamd/local.d/actions.conf
Normal file
5
docker/rspamd/local.d/actions.conf
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
no_action = 0;
|
||||
reject = null;
|
||||
add_header = null;
|
||||
rewrite_subject = null;
|
||||
greylist = null;
|
||||
5
docker/rspamd/local.d/milter_headers.conf
Normal file
5
docker/rspamd/local.d/milter_headers.conf
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Add "extended Rspamd headers"
|
||||
extended_spam_headers = true;
|
||||
|
||||
skip_local = false;
|
||||
skip_authenticated = false;
|
||||
3
docker/rspamd/local.d/options.inc
Normal file
3
docker/rspamd/local.d/options.inc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# rspamd options for happyDeliver
|
||||
# Disable Bayes learning to keep the setup stateless
|
||||
use_redis = false;
|
||||
6
docker/rspamd/local.d/worker-proxy.inc
Normal file
6
docker/rspamd/local.d/worker-proxy.inc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Enable rspamd milter proxy worker via Unix socket for Postfix integration
|
||||
bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
|
||||
upstream "local" {
|
||||
default = yes;
|
||||
self_scan = yes;
|
||||
}
|
||||
|
|
@ -48,3 +48,14 @@ rbl_timeout 5
|
|||
# Don't use user-specific rules
|
||||
user_scores_dsn_timeout 3
|
||||
user_scores_sql_override 0
|
||||
|
||||
# Disable Validity network rules
|
||||
dns_query_restriction deny sa-trusted.bondedsender.org
|
||||
dns_query_restriction deny sa-accredit.habeas.com
|
||||
dns_query_restriction deny bl.score.senderscore.com
|
||||
score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_CERTIFIED 0
|
||||
score RCVD_IN_VALIDITY_RPBL 0
|
||||
score RCVD_IN_VALIDITY_SAFE 0
|
||||
|
|
@ -33,6 +33,16 @@ stderr_logfile=/var/log/happydeliver/authentication_milter.log
|
|||
user=mail
|
||||
group=mail
|
||||
|
||||
# rspamd spam filter
|
||||
[program:rspamd]
|
||||
command=/usr/bin/rspamd -f -u rspamd -g mail
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=11
|
||||
stdout_logfile=/var/log/happydeliver/rspamd.log
|
||||
stderr_logfile=/var/log/happydeliver/rspamd_error.log
|
||||
user=root
|
||||
|
||||
# SpamAssassin daemon
|
||||
[program:spamd]
|
||||
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
||||
|
|
|
|||
40
go.mod
40
go.mod
|
|
@ -9,7 +9,7 @@ require (
|
|||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/net v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
|
|
@ -18,25 +18,25 @@ require (
|
|||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.2 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
|
@ -46,7 +46,7 @@ require (
|
|||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
|
|
@ -55,9 +55,9 @@ require (
|
|||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.56.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.16.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
|
@ -66,13 +66,13 @@ require (
|
|||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
82
go.sum
82
go.sum
|
|
@ -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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
|
|
@ -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.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg=
|
||||
github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
|
|
@ -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/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
|
|
@ -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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
|
|
@ -118,8 +118,8 @@ github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8
|
|||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -156,12 +156,12 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
|
|||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
|
||||
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
|
||||
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
||||
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
|
|
@ -201,11 +201,11 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
|
@ -213,13 +213,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
|||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -235,23 +235,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -264,8 +262,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import (
|
|||
type EmailAnalyzer interface {
|
||||
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
||||
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
||||
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||
}
|
||||
|
||||
// APIHandler implements the ServerInterface for handling API requests
|
||||
|
|
@ -359,7 +359,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Perform blacklist check using analyzer
|
||||
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_ip",
|
||||
|
|
@ -372,7 +372,8 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
|||
// Build response
|
||||
response := BlacklistCheckResponse{
|
||||
Ip: request.Ip,
|
||||
Checks: checks,
|
||||
Blacklists: checks,
|
||||
Whitelists: &whitelists,
|
||||
ListedCount: listedCount,
|
||||
Score: score,
|
||||
Grade: BlacklistCheckResponseGrade(grade),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ func declareFlags(o *Config) {
|
|||
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
|
||||
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
||||
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type Config struct {
|
|||
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
||||
RateLimit uint // API rate limit (requests per second per IP)
|
||||
SurveyURL url.URL // URL for user feedback survey
|
||||
CustomLogoURL string // URL for custom logo image in the web UI
|
||||
}
|
||||
|
||||
// DatabaseConfig contains database connection settings
|
||||
|
|
@ -64,6 +65,7 @@ type AnalysisConfig struct {
|
|||
DNSTimeout time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
RBLs []string
|
||||
DNSWLs []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +89,7 @@ func DefaultConfig() *Config {
|
|||
DNSTimeout: 5 * time.Second,
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
RBLs: []string{},
|
||||
DNSWLs: []string{},
|
||||
CheckAllIPs: false, // By default, only check the first IP
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
|||
cfg.Analysis.DNSTimeout,
|
||||
cfg.Analysis.HTTPTimeout,
|
||||
cfg.Analysis.RBLs,
|
||||
cfg.Analysis.DNSWLs,
|
||||
cfg.Analysis.CheckAllIPs,
|
||||
)
|
||||
|
||||
|
|
@ -120,22 +121,28 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
|
|||
return dnsResults, score, grade
|
||||
}
|
||||
|
||||
// CheckBlacklistIP checks a single IP address against DNS blacklists
|
||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
|
||||
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
|
||||
// Check the IP against all configured RBLs
|
||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||
if err != nil {
|
||||
return nil, 0, 0, "", err
|
||||
return nil, nil, 0, 0, "", err
|
||||
}
|
||||
|
||||
// Calculate score using the existing function
|
||||
// Create a minimal RBLResults structure for scoring
|
||||
results := &RBLResults{
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
||||
IPsChecked: []string{ip},
|
||||
ListedCount: listedCount,
|
||||
}
|
||||
score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results)
|
||||
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
|
||||
|
||||
return checks, listedCount, score, grade, nil
|
||||
// Check the IP against all configured DNSWLs (informational only)
|
||||
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
||||
if err != nil {
|
||||
whitelists = nil
|
||||
}
|
||||
|
||||
return checks, whitelists, listedCount, score, grade, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
|
@ -37,8 +38,10 @@ import (
|
|||
|
||||
// ContentAnalyzer analyzes email content (HTML, links, images)
|
||||
type ContentAnalyzer struct {
|
||||
Timeout time.Duration
|
||||
httpClient *http.Client
|
||||
Timeout time.Duration
|
||||
httpClient *http.Client
|
||||
listUnsubscribeURLs []string // URLs from List-Unsubscribe header
|
||||
hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
}
|
||||
|
||||
// NewContentAnalyzer creates a new content analyzer with configurable timeout
|
||||
|
|
@ -110,6 +113,13 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
|
|||
|
||||
results.IsMultipart = len(email.Parts) > 1
|
||||
|
||||
// Parse List-Unsubscribe header URLs for use in link detection
|
||||
c.listUnsubscribeURLs = email.GetListUnsubscribeURLs()
|
||||
|
||||
// Check for one-click unsubscribe support
|
||||
listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post")
|
||||
c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click")
|
||||
|
||||
// Get HTML and text parts
|
||||
htmlParts := email.GetHTMLParts()
|
||||
textParts := email.GetTextParts()
|
||||
|
|
@ -331,9 +341,14 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
|
|||
|
||||
// isUnsubscribeLink checks if a link is an unsubscribe link
|
||||
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
||||
// First check: does the href match a URL from the List-Unsubscribe header?
|
||||
if slices.Contains(c.listUnsubscribeURLs, href) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check href for unsubscribe keywords
|
||||
lowerHref := strings.ToLower(href)
|
||||
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
|
||||
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"}
|
||||
for _, keyword := range unsubKeywords {
|
||||
if strings.Contains(lowerHref, keyword) {
|
||||
return true
|
||||
|
|
@ -439,7 +454,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
|||
// Extract the actual destination domain/email based on scheme
|
||||
var actualDomain string
|
||||
|
||||
if parsedURL.Scheme == "mailto" {
|
||||
switch parsedURL.Scheme {
|
||||
case "mailto":
|
||||
// Extract email address from mailto: URL
|
||||
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
|
||||
mailtoAddr := parsedURL.Opaque
|
||||
|
|
@ -457,7 +473,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
|||
} else {
|
||||
return false // Invalid mailto
|
||||
}
|
||||
} else if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
|
||||
case "http":
|
||||
case "https":
|
||||
// Check if URL has a host
|
||||
if parsedURL.Host == "" {
|
||||
return false
|
||||
|
|
@ -469,7 +486,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
|||
actualDomain = actualDomain[:idx]
|
||||
}
|
||||
actualDomain = strings.ToLower(actualDomain)
|
||||
} else {
|
||||
default:
|
||||
// Skip checks for other URL schemes (tel, etc.)
|
||||
return false
|
||||
}
|
||||
|
|
@ -492,10 +509,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
|||
"email us", "contact us", "send email", "get in touch", "reach out",
|
||||
"contact", "email", "write to us",
|
||||
}
|
||||
for _, generic := range genericTexts {
|
||||
if linkText == generic {
|
||||
return false
|
||||
}
|
||||
if slices.Contains(genericTexts, linkText) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract domain-like patterns from link text using regex
|
||||
|
|
@ -562,10 +577,8 @@ func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) boo
|
|||
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
||||
"buff.ly", "is.gd", "bl.ink", "short.io",
|
||||
}
|
||||
for _, shortener := range shorteners {
|
||||
if strings.ToLower(parsedURL.Host) == shortener {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for excessive subdomains (possible obfuscation)
|
||||
|
|
@ -724,6 +737,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
|||
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
||||
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
||||
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
||||
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
|
||||
}
|
||||
|
||||
// Calculate text-to-image ratio (inverse of image-to-text)
|
||||
|
|
@ -870,8 +884,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
|||
|
||||
// Unsubscribe methods
|
||||
if results.HasUnsubscribe {
|
||||
methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
|
||||
analysis.UnsubscribeMethods = &methods
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
|
||||
}
|
||||
|
||||
for _, url := range c.listUnsubscribeURLs {
|
||||
if strings.HasPrefix(url, "mailto:") {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
|
||||
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
|
|
|||
|
|
@ -144,6 +144,74 @@ func TestIsUnsubscribeLink(t *testing.T) {
|
|||
linkText: "Read more",
|
||||
expected: false,
|
||||
},
|
||||
// Multilingual keyword detection - URL path
|
||||
{
|
||||
name: "German abmelden in URL",
|
||||
href: "https://example.com/abmelden?id=42",
|
||||
linkText: "Click here",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
|
||||
href: "https://example.com/se-desabonner?id=42",
|
||||
linkText: "Click here",
|
||||
expected: false,
|
||||
},
|
||||
// Multilingual keyword detection - link text
|
||||
{
|
||||
name: "German Abmelden in link text",
|
||||
href: "https://example.com/manage?id=42&lang=de",
|
||||
linkText: "Abmelden",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "French Se désabonner in link text",
|
||||
href: "https://example.com/manage?id=42&lang=fr",
|
||||
linkText: "Se désabonner",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Russian Отписаться in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ru",
|
||||
linkText: "Отписаться",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Chinese 退订 in link text",
|
||||
href: "https://example.com/manage?id=42&lang=zh",
|
||||
linkText: "退订",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Japanese 登録を取り消す in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ja",
|
||||
linkText: "登録を取り消す",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Korean 구독 해지 in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ko",
|
||||
linkText: "구독 해지",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Dutch Uitschrijven in link text",
|
||||
href: "https://example.com/manage?id=42&lang=nl",
|
||||
linkText: "Uitschrijven",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Polish Odsubskrybuj in link text",
|
||||
href: "https://example.com/manage?id=42&lang=pl",
|
||||
linkText: "Odsubskrybuj",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Turkish Üyeliği sonlandır in link text",
|
||||
href: "https://example.com/manage?id=42&lang=tr",
|
||||
linkText: "Üyeliği sonlandır",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
|
|
|||
|
|
@ -109,6 +109,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
maxGrade -= 1
|
||||
}
|
||||
|
||||
// Check MIME-Version header (-5 points if present but not "1.0")
|
||||
if check, exists := headers["mime-version"]; exists && check.Present {
|
||||
if check.Valid != nil && !*check.Valid {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
|
||||
// Check Message-ID format (10 points)
|
||||
if check, exists := headers["message-id"]; exists && check.Present {
|
||||
// If Valid is set and true, award points
|
||||
|
|
@ -266,6 +273,10 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
|||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
// Check MIME-Version header (recommended but absence is not penalized)
|
||||
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
|
||||
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
|
||||
|
||||
// Check optional headers
|
||||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
||||
for _, headerName := range optionalHeaders {
|
||||
|
|
@ -320,12 +331,21 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
|||
valid = false
|
||||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||||
}
|
||||
if len(email.Header["Message-Id"]) > 1 {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
|
||||
}
|
||||
case "Date":
|
||||
// Validate date format
|
||||
if _, err := h.parseEmailDate(value); err != nil {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
||||
}
|
||||
case "MIME-Version":
|
||||
if value != "1.0" {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
|
||||
}
|
||||
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
||||
// Parse address header using net/mail and get normalized address
|
||||
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
||||
|
|
|
|||
|
|
@ -256,6 +256,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
|
|||
}
|
||||
|
||||
for _, headerName := range saHeaders {
|
||||
if values, ok := e.Header[headerName]; ok && len(values) > 0 {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
headers[headerName] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// GetRspamdHeaders extracts rspamd-related headers
|
||||
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
|
||||
headers := make(map[string]string)
|
||||
|
||||
rspamdHeaders := []string{
|
||||
"X-Spamd-Result",
|
||||
"X-Rspamd-Score",
|
||||
"X-Rspamd-Action",
|
||||
"X-Rspamd-Server",
|
||||
}
|
||||
|
||||
for _, headerName := range rspamdHeaders {
|
||||
if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
|
|
@ -301,3 +328,20 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
|
|||
func (e *EmailMessage) HasHeader(key string) bool {
|
||||
return e.Header.Get(key) != ""
|
||||
}
|
||||
|
||||
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
|
||||
// The header format is: <url1>, <url2>, ...
|
||||
func (e *EmailMessage) GetListUnsubscribeURLs() []string {
|
||||
value := e.Header.Get("List-Unsubscribe")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var urls []string
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") {
|
||||
urls = append(urls, part[1:len(part)-1])
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,17 +27,21 @@ import (
|
|||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// RBLChecker checks IP addresses against DNS-based blacklists
|
||||
type RBLChecker struct {
|
||||
Timeout time.Duration
|
||||
RBLs []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
resolver *net.Resolver
|
||||
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
||||
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
|
||||
type DNSListChecker struct {
|
||||
Timeout time.Duration
|
||||
Lists []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
|
||||
resolver *net.Resolver
|
||||
informationalSet map[string]bool // Lists whose hits don't count toward the score
|
||||
}
|
||||
|
||||
// DefaultRBLs is a list of commonly used RBL providers
|
||||
|
|
@ -48,40 +52,83 @@ var DefaultRBLs = []string{
|
|||
"b.barracudacentral.org", // Barracuda
|
||||
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
||||
"psbl.surriel.com", // PSBL
|
||||
"dnsbl.dronebl.org", // DroneBL
|
||||
"bl.mailspike.net", // Mailspike BL
|
||||
"z.mailspike.net", // Mailspike Z
|
||||
"bl.rbl-dns.com", // RBL-DNS
|
||||
"bl.nszones.com", // NSZones
|
||||
}
|
||||
|
||||
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
|
||||
// These are typically broader lists where being listed is less definitive.
|
||||
var DefaultInformationalRBLs = []string{
|
||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
|
||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
|
||||
}
|
||||
|
||||
// DefaultDNSWLs is a list of commonly used DNSWL providers
|
||||
var DefaultDNSWLs = []string{
|
||||
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
|
||||
"swl.spamhaus.org", // Spamhaus Safe Whitelist
|
||||
}
|
||||
|
||||
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker {
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second // Default timeout
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if len(rbls) == 0 {
|
||||
rbls = DefaultRBLs
|
||||
}
|
||||
return &RBLChecker{
|
||||
Timeout: timeout,
|
||||
RBLs: rbls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
|
||||
for _, rbl := range DefaultInformationalRBLs {
|
||||
informationalSet[rbl] = true
|
||||
}
|
||||
return &DNSListChecker{
|
||||
Timeout: timeout,
|
||||
Lists: rbls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
filterErrorCodes: true,
|
||||
resolver: &net.Resolver{PreferGo: true},
|
||||
informationalSet: informationalSet,
|
||||
}
|
||||
}
|
||||
|
||||
// RBLResults represents the results of RBL checks
|
||||
type RBLResults struct {
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int
|
||||
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
|
||||
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if len(dnswls) == 0 {
|
||||
dnswls = DefaultDNSWLs
|
||||
}
|
||||
return &DNSListChecker{
|
||||
Timeout: timeout,
|
||||
Lists: dnswls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
filterErrorCodes: false,
|
||||
resolver: &net.Resolver{PreferGo: true},
|
||||
informationalSet: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckEmail checks all IPs found in the email headers against RBLs
|
||||
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||
results := &RBLResults{
|
||||
// DNSListResults represents the results of DNS list checks
|
||||
type DNSListResults struct {
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int // Total listings including informational entries
|
||||
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||
}
|
||||
|
||||
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||
results := &DNSListResults{
|
||||
Checks: make(map[string][]api.BlacklistCheck),
|
||||
}
|
||||
|
||||
// Extract IPs from Received headers
|
||||
ips := r.extractIPs(email)
|
||||
if len(ips) == 0 {
|
||||
return results
|
||||
|
|
@ -89,17 +136,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
|||
|
||||
results.IPsChecked = ips
|
||||
|
||||
// Check each IP against all RBLs
|
||||
for _, ip := range ips {
|
||||
for _, rbl := range r.RBLs {
|
||||
check := r.checkIP(ip, rbl)
|
||||
for _, list := range r.Lists {
|
||||
check := r.checkIP(ip, list)
|
||||
results.Checks[ip] = append(results.Checks[ip], check)
|
||||
if check.Listed {
|
||||
results.ListedCount++
|
||||
if !r.informationalSet[list] {
|
||||
results.RelevantListedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only check the first IP unless CheckAllIPs is enabled
|
||||
if !r.CheckAllIPs {
|
||||
break
|
||||
}
|
||||
|
|
@ -108,20 +156,26 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
|||
return results
|
||||
}
|
||||
|
||||
// CheckIP checks a single IP address against all configured RBLs
|
||||
func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||
// Validate that it's a valid IP address
|
||||
// CheckIP checks a single IP address against all configured lists in parallel
|
||||
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||
if !r.isPublicIP(ip) {
|
||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||
}
|
||||
|
||||
var checks []api.BlacklistCheck
|
||||
listedCount := 0
|
||||
checks := make([]api.BlacklistCheck, len(r.Lists))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Check the IP against all RBLs
|
||||
for _, rbl := range r.RBLs {
|
||||
check := r.checkIP(ip, rbl)
|
||||
checks = append(checks, check)
|
||||
for i, list := range r.Lists {
|
||||
wg.Add(1)
|
||||
go func(i int, list string) {
|
||||
defer wg.Done()
|
||||
checks[i] = r.checkIP(ip, list)
|
||||
}(i, list)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
listedCount := 0
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
listedCount++
|
||||
}
|
||||
|
|
@ -131,27 +185,19 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
|||
}
|
||||
|
||||
// extractIPs extracts IP addresses from Received headers
|
||||
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
||||
var ips []string
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
// Get all Received headers
|
||||
receivedHeaders := email.Header["Received"]
|
||||
|
||||
// Regex patterns for IP addresses
|
||||
// Match IPv4: xxx.xxx.xxx.xxx
|
||||
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
|
||||
|
||||
// Look for IPs in Received headers
|
||||
for _, received := range receivedHeaders {
|
||||
// Find all IPv4 addresses
|
||||
matches := ipv4Pattern.FindAllString(received, -1)
|
||||
for _, match := range matches {
|
||||
// Skip private/reserved IPs
|
||||
if !r.isPublicIP(match) {
|
||||
continue
|
||||
}
|
||||
// Avoid duplicates
|
||||
if !seenIPs[match] {
|
||||
ips = append(ips, match)
|
||||
seenIPs[match] = true
|
||||
|
|
@ -159,13 +205,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
|||
}
|
||||
}
|
||||
|
||||
// If no IPs found in Received headers, try X-Originating-IP
|
||||
if len(ips) == 0 {
|
||||
originatingIP := email.Header.Get("X-Originating-IP")
|
||||
if originatingIP != "" {
|
||||
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
|
||||
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||
// Remove any whitespace
|
||||
cleanIP = strings.TrimSpace(cleanIP)
|
||||
matches := ipv4Pattern.FindString(cleanIP)
|
||||
if matches != "" && r.isPublicIP(matches) {
|
||||
|
|
@ -178,19 +221,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
|||
}
|
||||
|
||||
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||
func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
||||
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a private network
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Additional checks for reserved ranges
|
||||
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
|
|
@ -198,51 +238,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// checkIP checks a single IP against a single RBL
|
||||
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
||||
// checkIP checks a single IP against a single DNS list
|
||||
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
||||
check := api.BlacklistCheck{
|
||||
Rbl: rbl,
|
||||
Rbl: list,
|
||||
}
|
||||
|
||||
// Reverse the IP for DNSBL query
|
||||
reversedIP := r.reverseIP(ip)
|
||||
if reversedIP == "" {
|
||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||
return check
|
||||
}
|
||||
|
||||
// Construct DNSBL query: reversed-ip.rbl-domain
|
||||
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
|
||||
query := fmt.Sprintf("%s.%s", reversedIP, list)
|
||||
|
||||
// Perform DNS lookup with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||
if err != nil {
|
||||
// Most likely not listed (NXDOMAIN)
|
||||
if dnsErr, ok := err.(*net.DNSError); ok {
|
||||
if dnsErr.IsNotFound {
|
||||
check.Listed = false
|
||||
return check
|
||||
}
|
||||
}
|
||||
// Other DNS errors
|
||||
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||
return check
|
||||
}
|
||||
|
||||
// If we got a response, check the return code
|
||||
if len(addrs) > 0 {
|
||||
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
|
||||
check.Response = api.PtrTo(addrs[0])
|
||||
|
||||
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
|
||||
// These indicate RBL operational issues, not actual listings
|
||||
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
|
||||
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
||||
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
||||
check.Listed = false
|
||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]))
|
||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
||||
} else {
|
||||
// Normal listing response
|
||||
check.Listed = true
|
||||
}
|
||||
}
|
||||
|
|
@ -250,44 +282,47 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
|||
return check
|
||||
}
|
||||
|
||||
// reverseIP reverses an IPv4 address for DNSBL queries
|
||||
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
|
||||
// Example: 192.0.2.1 -> 1.2.0.192
|
||||
func (r *RBLChecker) reverseIP(ipStr string) string {
|
||||
func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Convert to IPv4
|
||||
ipv4 := ip.To4()
|
||||
if ipv4 == nil {
|
||||
return "" // IPv6 not supported yet
|
||||
}
|
||||
|
||||
// Reverse the octets
|
||||
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
||||
}
|
||||
|
||||
// CalculateRBLScore calculates the blacklist contribution to deliverability
|
||||
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) {
|
||||
// CalculateScore calculates the list contribution to deliverability.
|
||||
// Informational lists are not counted in the score.
|
||||
func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) {
|
||||
if results == nil || len(results.IPsChecked) == 0 {
|
||||
// No IPs to check, give benefit of doubt
|
||||
return 100, ""
|
||||
}
|
||||
|
||||
percentage := 100 - results.ListedCount*100/len(r.RBLs)
|
||||
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
||||
if scoringListCount <= 0 {
|
||||
return 100, "A+"
|
||||
}
|
||||
|
||||
percentage := 100 - results.RelevantListedCount*100/scoringListCount
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
||||
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
|
||||
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
||||
var listedIPs []string
|
||||
|
||||
for ip, rblChecks := range results.Checks {
|
||||
for _, check := range rblChecks {
|
||||
for ip, checks := range results.Checks {
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
listedIPs = append(listedIPs, ip)
|
||||
break // Only add the IP once
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -295,17 +330,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
|||
return listedIPs
|
||||
}
|
||||
|
||||
// GetRBLsForIP returns all RBLs that list a specific IP
|
||||
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
||||
var rbls []string
|
||||
// GetListsForIP returns all lists that match a specific IP
|
||||
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
|
||||
var lists []string
|
||||
|
||||
if rblChecks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range rblChecks {
|
||||
if checks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
rbls = append(rbls, check.Rbl)
|
||||
lists = append(lists, check.Rbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rbls
|
||||
return lists
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) {
|
|||
if checker.Timeout != tt.expectedTimeout {
|
||||
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||
}
|
||||
if len(checker.RBLs) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
||||
if len(checker.Lists) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
|
||||
}
|
||||
if checker.resolver == nil {
|
||||
t.Error("Resolver should not be nil")
|
||||
|
|
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
|
|||
func TestGetBlacklistScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *RBLResults
|
||||
results *DNSListResults
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
|
|
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "No IPs checked",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{},
|
||||
},
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Not listed on any RBL",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
},
|
||||
|
|
@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
},
|
||||
|
|
@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
},
|
||||
|
|
@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 3 RBLs",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 3,
|
||||
},
|
||||
|
|
@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 4+ RBLs",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
},
|
||||
|
|
@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := checker.CalculateRBLScore(tt.results)
|
||||
score, _ := checker.CalculateScore(tt.results)
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
|
|
@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetUniqueListedIPs(t *testing.T) {
|
||||
results := &RBLResults{
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
|
|
@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetRBLsForIP(t *testing.T) {
|
||||
results := &RBLResults{
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
|
|
@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rbls := checker.GetRBLsForIP(results, tt.ip)
|
||||
rbls := checker.GetListsForIP(results, tt.ip)
|
||||
|
||||
if len(rbls) != len(tt.expectedRBLs) {
|
||||
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ import (
|
|||
type ReportGenerator struct {
|
||||
authAnalyzer *AuthenticationAnalyzer
|
||||
spamAnalyzer *SpamAssassinAnalyzer
|
||||
rspamdAnalyzer *RspamdAnalyzer
|
||||
dnsAnalyzer *DNSAnalyzer
|
||||
rblChecker *RBLChecker
|
||||
rblChecker *DNSListChecker
|
||||
dnswlChecker *DNSListChecker
|
||||
contentAnalyzer *ContentAnalyzer
|
||||
headerAnalyzer *HeaderAnalyzer
|
||||
}
|
||||
|
|
@ -44,13 +46,16 @@ func NewReportGenerator(
|
|||
dnsTimeout time.Duration,
|
||||
httpTimeout time.Duration,
|
||||
rbls []string,
|
||||
dnswls []string,
|
||||
checkAllIPs bool,
|
||||
) *ReportGenerator {
|
||||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||
headerAnalyzer: NewHeaderAnalyzer(),
|
||||
}
|
||||
|
|
@ -63,8 +68,10 @@ type AnalysisResults struct {
|
|||
Content *ContentResults
|
||||
DNS *api.DNSResults
|
||||
Headers *api.HeaderAnalysis
|
||||
RBL *RBLResults
|
||||
RBL *DNSListResults
|
||||
DNSWL *DNSListResults
|
||||
SpamAssassin *api.SpamAssassinResult
|
||||
Rspamd *api.RspamdResult
|
||||
}
|
||||
|
||||
// AnalyzeEmail performs complete email analysis
|
||||
|
|
@ -78,7 +85,9 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
|
||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||
|
||||
return results
|
||||
|
|
@ -131,13 +140,29 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
blacklistScore := 0
|
||||
var blacklistGrade string
|
||||
if results.RBL != nil {
|
||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
|
||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
|
||||
}
|
||||
|
||||
spamScore := 0
|
||||
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
|
||||
|
||||
// Combine SpamAssassin and rspamd scores 50/50.
|
||||
// If only one filter ran (the other returns "" grade), use that filter's score alone.
|
||||
var spamScore int
|
||||
var spamGrade string
|
||||
if results.SpamAssassin != nil {
|
||||
spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||
switch {
|
||||
case saGrade == "" && rspamdGrade == "":
|
||||
spamScore = 0
|
||||
spamGrade = ""
|
||||
case saGrade == "":
|
||||
spamScore = rspamdScore
|
||||
spamGrade = rspamdGrade
|
||||
case rspamdGrade == "":
|
||||
spamScore = saScore
|
||||
spamGrade = saGrade
|
||||
default:
|
||||
spamScore = (saScore + rspamdScore) / 2
|
||||
spamGrade = MinGrade(saGrade, rspamdGrade)
|
||||
}
|
||||
|
||||
report.Summary = &api.ScoreSummary{
|
||||
|
|
@ -177,9 +202,27 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
report.Blacklists = &results.RBL.Checks
|
||||
}
|
||||
|
||||
// Add SpamAssassin result
|
||||
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
|
||||
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
|
||||
report.Whitelists = &results.DNSWL.Checks
|
||||
}
|
||||
|
||||
// Add SpamAssassin result with individual deliverability score
|
||||
if results.SpamAssassin != nil {
|
||||
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
|
||||
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
|
||||
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
||||
}
|
||||
report.Spamassassin = results.SpamAssassin
|
||||
|
||||
// Add rspamd result with individual deliverability score
|
||||
if results.Rspamd != nil {
|
||||
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
|
||||
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
|
||||
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
||||
}
|
||||
report.Rspamd = results.Rspamd
|
||||
|
||||
// Add raw headers
|
||||
if results.Email != nil && results.Email.RawHeaders != "" {
|
||||
report.RawHeaders = &results.Email.RawHeaders
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import (
|
|||
)
|
||||
|
||||
func TestNewReportGenerator(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
if gen == nil {
|
||||
t.Fatal("Expected report generator, got nil")
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAnalyzeEmail(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
|
||||
email := createTestEmail()
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateReport(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmail()
|
||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateRawEmail(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
155
pkg/analyzer/rspamd.go
Normal file
155
pkg/analyzer/rspamd.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||
const (
|
||||
rspamdDefaultRejectThreshold float32 = 15
|
||||
rspamdDefaultAddHeaderThreshold float32 = 6
|
||||
)
|
||||
|
||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||
type RspamdAnalyzer struct{}
|
||||
|
||||
// NewRspamdAnalyzer creates a new rspamd analyzer
|
||||
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
||||
return &RspamdAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||
headers := email.GetRspamdHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
|
||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
||||
result.Report = &report
|
||||
a.parseSpamdResult(spamdResult, result)
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Score as override/fallback for score
|
||||
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
|
||||
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Server
|
||||
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
|
||||
server := strings.TrimSpace(serverHeader)
|
||||
result.Server = &server
|
||||
}
|
||||
|
||||
// Derive IsSpam from score vs reject threshold.
|
||||
if result.Threshold > 0 {
|
||||
result.IsSpam = result.Score >= result.Threshold
|
||||
} else {
|
||||
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseSpamdResult parses the X-Spamd-Result header
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
||||
// Extract score and threshold from the first line
|
||||
// e.g. "default: False [-3.91 / 15.00]"
|
||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
|
||||
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
|
||||
result.Threshold = float32(threshold)
|
||||
|
||||
// No threshold? use default AddHeaderThreshold
|
||||
if result.Threshold <= 0 {
|
||||
result.Threshold = rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
|
||||
firstLine := strings.SplitN(header, ";", 2)[0]
|
||||
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
|
||||
result.IsSpam = true
|
||||
}
|
||||
|
||||
// Parse symbols: SYMBOL(score)[params]
|
||||
// Each symbol entry is separated by ";", so within each part we use a
|
||||
// greedy match to capture params that may contain nested brackets.
|
||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
matches := symbolRe.FindStringSubmatch(part)
|
||||
if len(matches) > 2 {
|
||||
name := matches[1]
|
||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||
sym := api.RspamdSymbol{
|
||||
Name: name,
|
||||
Score: float32(score),
|
||||
}
|
||||
if len(matches) > 3 && matches[3] != "" {
|
||||
params := matches[3]
|
||||
sym.Params = ¶ms
|
||||
}
|
||||
result.Symbols[name] = sym
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
||||
if result == nil {
|
||||
return 100, "" // rspamd not installed
|
||||
}
|
||||
|
||||
threshold := result.Threshold
|
||||
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
|
||||
|
||||
if percentage > 100 {
|
||||
return 100, "A+"
|
||||
} else if percentage < 0 {
|
||||
return 0, "F"
|
||||
}
|
||||
|
||||
// Linear scale between 0 and threshold
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
414
pkg/analyzer/rspamd_test.go
Normal file
414
pkg/analyzer/rspamd_test.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpamdResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedSymbols map[string]float32
|
||||
expectedSymParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Clean email negative score",
|
||||
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"DATE_IN_PAST": 0.10,
|
||||
"ALL_TRUSTED": -1.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ALL_TRUSTED": "trusted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Spam email True flag",
|
||||
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
||||
expectedScore: 16.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{
|
||||
"BAYES_99": 5.00,
|
||||
"SPOOFED_SENDER": 3.50,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"BAYES_99": "1.00",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold uses default",
|
||||
header: "default: False [1.00 / 0.00]",
|
||||
expectedScore: 1.00,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Symbol without params",
|
||||
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
||||
expectedScore: 2.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"MISSING_DATE": 1.00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Case-insensitive true flag",
|
||||
header: "default: true [8.00 / 6.00]",
|
||||
expectedScore: 8.00,
|
||||
expectedThreshold: 6.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold with symbols containing nested brackets in params",
|
||||
header: "default: False [0.90 / 0.00];\n" +
|
||||
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
|
||||
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
|
||||
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
|
||||
expectedScore: 0.90,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"ARC_REJECT": 1.00,
|
||||
"MIME_GOOD": -0.10,
|
||||
"MIME_TRACE": 0.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
|
||||
"MIME_GOOD": "multipart/alternative,text/plain",
|
||||
"MIME_TRACE": "0:+,1:+,2:~",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
analyzer.parseSpamdResult(tt.header, result)
|
||||
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
for symName, expectedScore := range tt.expectedSymbols {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Score != expectedScore {
|
||||
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
||||
}
|
||||
}
|
||||
for symName, expectedParam := range tt.expectedSymParams {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found for params check", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Params == nil {
|
||||
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
||||
} else if *sym.Params != expectedParam {
|
||||
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRspamd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedServer *string
|
||||
expectedSymCount int
|
||||
}{
|
||||
{
|
||||
name: "Full headers clean email",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
||||
"X-Rspamd-Score": "-3.91",
|
||||
"X-Rspamd-Server": "mail.example.com",
|
||||
},
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "X-Rspamd-Score overrides spamd result score",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
||||
"X-Rspamd-Score": "3.50",
|
||||
},
|
||||
expectedScore: 3.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "Spam email above threshold",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
||||
"X-Rspamd-Score": "16.00",
|
||||
},
|
||||
expectedScore: 16.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "2.00",
|
||||
},
|
||||
expectedScore: 2.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "7.00",
|
||||
},
|
||||
expectedScore: 7.00,
|
||||
expectedIsSpam: true,
|
||||
},
|
||||
{
|
||||
name: "Server header is trimmed",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "1.00",
|
||||
"X-Rspamd-Server": " rspamd-01 ",
|
||||
},
|
||||
expectedScore: 1.00,
|
||||
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
for k, v := range tt.headers {
|
||||
email.Header[k] = []string{v}
|
||||
}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
if tt.expectedServer != nil {
|
||||
if result.Server == nil {
|
||||
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
||||
} else if *result.Server != *tt.expectedServer {
|
||||
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
||||
}
|
||||
}
|
||||
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
||||
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRspamdScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *api.RspamdResult
|
||||
expectedScore int
|
||||
expectedGrade string
|
||||
}{
|
||||
{
|
||||
name: "Nil result (rspamd not installed)",
|
||||
result: nil,
|
||||
expectedScore: 100,
|
||||
expectedGrade: "",
|
||||
},
|
||||
{
|
||||
name: "Score well below threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: -3.91,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A+",
|
||||
},
|
||||
{
|
||||
name: "Score at zero",
|
||||
result: &api.RspamdResult{
|
||||
Score: 0,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A",
|
||||
},
|
||||
{
|
||||
name: "Score at threshold (half of 2*threshold)",
|
||||
result: &api.RspamdResult{
|
||||
Score: 15.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
||||
expectedScore: 50,
|
||||
},
|
||||
{
|
||||
name: "Score above 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 31.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
{
|
||||
name: "Score exactly at 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 30.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(30*100/30) = 100 - 100 = 0
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
||||
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
||||
BAYES_HAM(-3.00)[99%];
|
||||
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
||||
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
||||
FROM_HAS_DN(0.00)[];
|
||||
MIME_GOOD(-0.10)[text/plain];
|
||||
X-Rspamd-Score: -3.91
|
||||
X-Rspamd-Server: rspamd-01.example.com
|
||||
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
||||
From: sender@example.com
|
||||
To: test@happydomain.org
|
||||
Subject: Test email
|
||||
Message-ID: <test123@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain
|
||||
|
||||
Hello world`
|
||||
|
||||
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.IsSpam {
|
||||
t.Error("Expected IsSpam=false")
|
||||
}
|
||||
if result.Score != -3.91 {
|
||||
t.Errorf("Score = %v, want -3.91", result.Score)
|
||||
}
|
||||
if result.Threshold != 15.00 {
|
||||
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
||||
}
|
||||
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
||||
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
||||
}
|
||||
|
||||
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
||||
for _, sym := range expectedSymbols {
|
||||
if _, ok := result.Symbols[sym]; !ok {
|
||||
t.Errorf("Symbol %s not found", sym)
|
||||
}
|
||||
}
|
||||
|
||||
score, _ := analyzer.CalculateRspamdScore(result)
|
||||
if score != 100 {
|
||||
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,3 +69,31 @@ func ScoreToGradeKind(score int) string {
|
|||
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||
return api.ReportGrade(ScoreToGrade(score))
|
||||
}
|
||||
|
||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||
func gradeRank(grade string) int {
|
||||
switch grade {
|
||||
case "A+":
|
||||
return 6
|
||||
case "A":
|
||||
return 5
|
||||
case "B":
|
||||
return 4
|
||||
case "C":
|
||||
return 3
|
||||
case "D":
|
||||
return 2
|
||||
case "E":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MinGrade returns the minimal (worse) grade between the two given grades
|
||||
func MinGrade(a, b string) string {
|
||||
if gradeRank(a) <= gradeRank(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
|||
}
|
||||
|
||||
// Parse X-Spam-Status header
|
||||
if statusHeader, ok := headers["X-Spam-Status"]; ok {
|
||||
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
|
||||
a.parseSpamStatus(statusHeader, result)
|
||||
}
|
||||
|
||||
|
|
|
|||
1168
web/package-lock.json
generated
1168
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,7 @@
|
|||
"generate:api": "openapi-ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@hey-api/openapi-ts": "0.86.10",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^16.4.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.39.5",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -67,6 +66,10 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
|||
appConfig["rbls"] = cfg.Analysis.RBLs
|
||||
}
|
||||
|
||||
if cfg.CustomLogoURL != "" {
|
||||
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
||||
}
|
||||
|
||||
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
||||
log.Println("Unable to generate JSON config to inject in web application")
|
||||
} else {
|
||||
|
|
@ -140,7 +143,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
v, _ := ioutil.ReadAll(resp.Body)
|
||||
v, _ := io.ReadAll(resp.Body)
|
||||
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
|
||||
|
|
@ -167,7 +170,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
|||
if indexTpl == nil {
|
||||
// Create template from file
|
||||
f, _ := Assets.Open("index.html")
|
||||
v, _ := ioutil.ReadAll(f)
|
||||
v, _ := io.ReadAll(f)
|
||||
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
:root {
|
||||
--bs-primary: #1cb487;
|
||||
--bs-primary-rgb: 28, 180, 135;
|
||||
--bs-link-color-rgb: 28, 180, 135;
|
||||
--bs-link-hover-color-rgb: 17, 112, 84;
|
||||
--bs-tertiary-bg: #e7e8e8;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -8,6 +11,10 @@ body {
|
|||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.bg-tertiary {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
case "domain_pass":
|
||||
case "orgdomain_pass":
|
||||
return "text-success";
|
||||
case "permerror":
|
||||
case "error":
|
||||
case "fail":
|
||||
case "missing":
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
case "neutral":
|
||||
case "invalid":
|
||||
case "null":
|
||||
case "permerror":
|
||||
case "error":
|
||||
case "null_smtp":
|
||||
case "null_header":
|
||||
|
|
@ -96,281 +98,442 @@
|
|||
</h4>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
<div class="list-group-item" id="authentication-iprev">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.iprev.result, true)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>IP Reverse DNS</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.iprev.result, true)}">
|
||||
{authentication.iprev.result}
|
||||
</span>
|
||||
{#if authentication.iprev.ip}
|
||||
<div class="small">
|
||||
<strong>IP Address:</strong>
|
||||
<span class="text-muted">{authentication.iprev.ip}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.hostname}
|
||||
<div class="small">
|
||||
<strong>Hostname:</strong>
|
||||
<span class="text-muted">{authentication.iprev.hostname}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.iprev.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
<div class="list-group-item" id="authentication-iprev">
|
||||
<div class="d-flex align-items-start">
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.iprev.result,
|
||||
true,
|
||||
)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>IP Reverse DNS</strong>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.iprev.result,
|
||||
true,
|
||||
)}"
|
||||
>
|
||||
{authentication.iprev.result}
|
||||
</span>
|
||||
{#if authentication.iprev.ip}
|
||||
<div class="small">
|
||||
<strong>IP Address:</strong>
|
||||
<span class="text-muted">{authentication.iprev.ip}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.hostname}
|
||||
<div class="small">
|
||||
<strong>Hostname:</strong>
|
||||
<span class="text-muted">{authentication.iprev.hostname}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.iprev.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SPF (Required) -->
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-start" id="authentication-spf">
|
||||
{#if authentication.spf}
|
||||
<i class="bi {getAuthResultIcon(authentication.spf.result, true)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>SPF</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.spf.result, true)}">
|
||||
{authentication.spf.result}
|
||||
</span>
|
||||
{#if authentication.spf.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.spf.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.spf.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.spf.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>SPF</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText('missing')}
|
||||
</span>
|
||||
<div class="text-muted small">SPF record is required for proper email authentication</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- DKIM (Required) -->
|
||||
<div class="list-group-item" id="authentication-dkim">
|
||||
{#if authentication.dkim && authentication.dkim.length > 0}
|
||||
{#each authentication.dkim as dkim, i}
|
||||
<div class="d-flex align-items-start" class:mt-3={i > 0}>
|
||||
<i class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(dkim.result, true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ''}</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}">
|
||||
{dkim.result}
|
||||
</span>
|
||||
{#if dkim.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{dkim.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if dkim.selector}
|
||||
<div class="small">
|
||||
<strong>Selector:</strong>
|
||||
<span class="text-muted">{dkim.selector}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if dkim.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{dkim.details}</pre>
|
||||
{/if}
|
||||
<!-- SPF (Required) -->
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-start" id="authentication-spf">
|
||||
{#if authentication.spf}
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.spf.result,
|
||||
true,
|
||||
)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>SPF</strong>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.spf.result,
|
||||
true,
|
||||
)}"
|
||||
>
|
||||
{authentication.spf.result}
|
||||
</span>
|
||||
{#if authentication.spf.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.spf.domain}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if authentication.spf.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.spf.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>DKIM</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText('missing')}
|
||||
</span>
|
||||
<div class="text-muted small">DKIM signature is required for proper email authentication</div>
|
||||
<i
|
||||
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
|
||||
'missing',
|
||||
true,
|
||||
)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>SPF</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText("missing")}
|
||||
</span>
|
||||
<div class="text-muted small">
|
||||
SPF record is required for proper email authentication
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- X-Google-DKIM (Optional) -->
|
||||
{#if authentication.x_google_dkim}
|
||||
<div class="list-group-item" id="authentication-x-google-dkim">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.x_google_dkim.result, false)} {getAuthResultClass(authentication.x_google_dkim.result, false)} me-2 fs-5"></i>
|
||||
<!-- DKIM (Required) -->
|
||||
<div class="list-group-item" id="authentication-dkim">
|
||||
{#if authentication.dkim && authentication.dkim.length > 0}
|
||||
{#each authentication.dkim as dkim, i}
|
||||
<div class="d-flex align-items-start" class:mt-3={i > 0}>
|
||||
<i
|
||||
class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(
|
||||
dkim.result,
|
||||
true,
|
||||
)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>X-Google-DKIM</strong>
|
||||
<i class="bi bi-info-circle text-muted ms-1" title="Google's internal DKIM signature for messages routed through Gmail infrastructure"></i>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_google_dkim.result, false)}">
|
||||
{authentication.x_google_dkim.result}
|
||||
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""}</strong
|
||||
>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}"
|
||||
>
|
||||
{dkim.result}
|
||||
</span>
|
||||
{#if authentication.x_google_dkim.domain}
|
||||
{#if dkim.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.x_google_dkim.domain}</span>
|
||||
<span class="text-muted">{dkim.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.x_google_dkim.selector}
|
||||
{#if dkim.selector}
|
||||
<div class="small">
|
||||
<strong>Selector:</strong>
|
||||
<span class="text-muted">{authentication.x_google_dkim.selector}</span>
|
||||
<span class="text-muted">{dkim.selector}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.x_google_dkim.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.x_google_dkim.details}</pre>
|
||||
{#if dkim.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{dkim.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- X-Aligned-From (Disabled) -->
|
||||
{#if authentication.x_aligned_from}
|
||||
<div class="list-group-item" id="authentication-x-aligned-from">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.x_aligned_from.result, false)} {getAuthResultClass(authentication.x_aligned_from.result, false)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>X-Aligned-From</strong>
|
||||
<i class="bi bi-info-circle text-muted ms-1" title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."></i>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_aligned_from.result, false)}">
|
||||
{authentication.x_aligned_from.result}
|
||||
</span>
|
||||
{#if authentication.x_aligned_from.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.x_aligned_from.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.x_aligned_from.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.x_aligned_from.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- DMARC (Required) -->
|
||||
<div class="list-group-item" id="authentication-dmarc">
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.dmarc}
|
||||
<i class="bi {getAuthResultIcon(authentication.dmarc.result, true)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>DMARC</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.dmarc.result, true)}">
|
||||
{authentication.dmarc.result}
|
||||
</span>
|
||||
{#if authentication.dmarc.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.dmarc.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet DMARCPolicy(policy: string)}
|
||||
<div class="small">
|
||||
<strong>Policy:</strong>
|
||||
<span
|
||||
class="fw-bold"
|
||||
class:text-success={policy == "reject"}
|
||||
class:text-warning={policy == "quarantine"}
|
||||
class:text-danger={policy == "none"}
|
||||
class:bg-warning={policy != "none" && policy != "quarantine" && policy != "reject"}
|
||||
>
|
||||
{policy}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if authentication.dmarc.result != "none"}
|
||||
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
|
||||
{@const policy = authentication.dmarc.details.replace(/^.*policy.published-domain-policy=([^\s]+).*$/, "$1")}
|
||||
{@render DMARCPolicy(policy)}
|
||||
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
|
||||
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if authentication.dmarc.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
|
||||
{/if}
|
||||
<i
|
||||
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
|
||||
'missing',
|
||||
true,
|
||||
)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>DKIM</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText("missing")}
|
||||
</span>
|
||||
<div class="text-muted small">
|
||||
DKIM signature is required for proper email authentication
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>DMARC</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText('missing')}
|
||||
</span>
|
||||
<div class="text-muted small">DMARC policy is required for proper email authentication</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- X-Google-DKIM (Optional) -->
|
||||
{#if authentication.x_google_dkim}
|
||||
<div class="list-group-item" id="authentication-x-google-dkim">
|
||||
<div class="d-flex align-items-start">
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.x_google_dkim.result,
|
||||
false,
|
||||
)} {getAuthResultClass(
|
||||
authentication.x_google_dkim.result,
|
||||
false,
|
||||
)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>X-Google-DKIM</strong>
|
||||
<i
|
||||
class="bi bi-info-circle text-muted ms-1"
|
||||
title="Google's internal DKIM signature for messages routed through Gmail infrastructure"
|
||||
></i>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.x_google_dkim.result,
|
||||
false,
|
||||
)}"
|
||||
>
|
||||
{authentication.x_google_dkim.result}
|
||||
</span>
|
||||
{#if authentication.x_google_dkim.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.x_google_dkim.domain}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.x_google_dkim.selector}
|
||||
<div class="small">
|
||||
<strong>Selector:</strong>
|
||||
<span class="text-muted"
|
||||
>{authentication.x_google_dkim.selector}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.x_google_dkim.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.x_google_dkim
|
||||
.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- BIMI (Optional) -->
|
||||
<div class="list-group-item" id="authentication-bimi">
|
||||
<!-- X-Aligned-From (Disabled) -->
|
||||
{#if authentication.x_aligned_from}
|
||||
<div class="list-group-item" id="authentication-x-aligned-from">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.bimi && authentication.bimi.result != "none"}
|
||||
<i class="bi {getAuthResultIcon(authentication.bimi.result, false)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.bimi.result, false)}">
|
||||
{authentication.bimi.result}
|
||||
</span>
|
||||
{#if authentication.bimi.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if authentication.bimi && authentication.bimi.result == "none"}
|
||||
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span class="text-uppercase ms-2 text-warning">
|
||||
NONE
|
||||
</span>
|
||||
<div class="text-muted small">Brand Indicators for Message Identification</div>
|
||||
{#if authentication.bimi.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span class="text-uppercase ms-2 text-muted">
|
||||
Optional
|
||||
</span>
|
||||
<div class="text-muted small">Brand Indicators for Message Identification (optional enhancement)</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ARC (Optional) -->
|
||||
{#if authentication.arc}
|
||||
<div class="list-group-item" id="authentication-arc">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.arc.result, false)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>ARC</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.arc.result, false)}">
|
||||
{authentication.arc.result}
|
||||
</span>
|
||||
{#if authentication.arc.chain_length}
|
||||
<div class="text-muted small">Chain length: {authentication.arc.chain_length}</div>
|
||||
{/if}
|
||||
{#if authentication.arc.details}
|
||||
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.arc.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.x_aligned_from.result,
|
||||
false,
|
||||
)} {getAuthResultClass(
|
||||
authentication.x_aligned_from.result,
|
||||
false,
|
||||
)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>X-Aligned-From</strong>
|
||||
<i
|
||||
class="bi bi-info-circle text-muted ms-1"
|
||||
title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."
|
||||
></i>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.x_aligned_from.result,
|
||||
false,
|
||||
)}"
|
||||
>
|
||||
{authentication.x_aligned_from.result}
|
||||
</span>
|
||||
{#if authentication.x_aligned_from.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted"
|
||||
>{authentication.x_aligned_from.domain}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.x_aligned_from.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.x_aligned_from
|
||||
.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- DMARC (Required) -->
|
||||
<div class="list-group-item" id="authentication-dmarc">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.dmarc}
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.dmarc.result,
|
||||
true,
|
||||
)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>DMARC</strong>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.dmarc.result,
|
||||
true,
|
||||
)}"
|
||||
>
|
||||
{authentication.dmarc.result}
|
||||
</span>
|
||||
{#if authentication.dmarc.domain}
|
||||
<div class="small">
|
||||
<strong>Domain:</strong>
|
||||
<span class="text-muted">{authentication.dmarc.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet DMARCPolicy(policy: string)}
|
||||
<div class="small">
|
||||
<strong>Policy:</strong>
|
||||
<span
|
||||
class="fw-bold"
|
||||
class:text-success={policy == "reject"}
|
||||
class:text-warning={policy == "quarantine"}
|
||||
class:text-danger={policy == "none"}
|
||||
class:bg-warning={policy != "none" &&
|
||||
policy != "quarantine" &&
|
||||
policy != "reject"}
|
||||
>
|
||||
{policy}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if authentication.dmarc.result != "none"}
|
||||
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
|
||||
{@const policy = authentication.dmarc.details.replace(
|
||||
/^.*policy.published-domain-policy=([^\s]+).*$/,
|
||||
"$1",
|
||||
)}
|
||||
{@render DMARCPolicy(policy)}
|
||||
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
|
||||
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if authentication.dmarc.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i
|
||||
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
|
||||
'missing',
|
||||
true,
|
||||
)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>DMARC</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText("missing")}
|
||||
</span>
|
||||
<div class="text-muted small">
|
||||
DMARC policy is required for proper email authentication
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BIMI (Optional) -->
|
||||
<div class="list-group-item" id="authentication-bimi">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.bimi && authentication.bimi.result != "none"}
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.bimi.result,
|
||||
false,
|
||||
)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.bimi.result,
|
||||
false,
|
||||
)}"
|
||||
>
|
||||
{authentication.bimi.result}
|
||||
</span>
|
||||
{#if authentication.bimi.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if authentication.bimi && authentication.bimi.result == "none"}
|
||||
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span class="text-uppercase ms-2 text-warning"> NONE </span>
|
||||
<div class="text-muted small">
|
||||
Brand Indicators for Message Identification
|
||||
</div>
|
||||
{#if authentication.bimi.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span class="text-uppercase ms-2 text-muted"> Optional </span>
|
||||
<div class="text-muted small">
|
||||
Brand Indicators for Message Identification (optional enhancement)
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ARC (Optional) -->
|
||||
{#if authentication.arc}
|
||||
<div class="list-group-item" id="authentication-arc">
|
||||
<div class="d-flex align-items-start">
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.arc.result,
|
||||
false,
|
||||
)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>ARC</strong>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.arc.result,
|
||||
false,
|
||||
)}"
|
||||
>
|
||||
{authentication.arc.result}
|
||||
</span>
|
||||
{#if authentication.arc.chain_length}
|
||||
<div class="text-muted small">
|
||||
Chain length: {authentication.arc.chain_length}
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.arc.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.arc.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,21 @@
|
|||
<script lang="ts">
|
||||
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import EmailPathCard from "./EmailPathCard.svelte";
|
||||
|
||||
interface Props {
|
||||
blacklists: Record<string, BlacklistCheck[]>;
|
||||
blacklistGrade?: string;
|
||||
blacklistScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
}
|
||||
|
||||
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="rbl-details">
|
||||
<div
|
||||
class="card-header"
|
||||
class:bg-white={$theme === 'light'}
|
||||
class:bg-dark={$theme !== 'light'}
|
||||
>
|
||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-shield-exclamation me-2"></i>
|
||||
Blacklist Checks
|
||||
|
|
@ -39,11 +33,7 @@
|
|||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{#if receivedChain}
|
||||
<EmailPathCard {receivedChain} />
|
||||
{/if}
|
||||
|
||||
<div class="row row-cols-1 row-cols-lg-2">
|
||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
||||
{#each Object.entries(blacklists) as [ip, checks]}
|
||||
<div class="col mb-3">
|
||||
<h5 class="text-muted">
|
||||
|
|
@ -54,9 +44,19 @@
|
|||
<tbody>
|
||||
{#each checks as check}
|
||||
<tr>
|
||||
<td title={check.response || '-'}>
|
||||
<span class="badge {check.listed ? 'bg-danger' : check.error ? 'bg-dark' : 'bg-success'}">
|
||||
{check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')}
|
||||
<td title={check.response || "-"}>
|
||||
<span
|
||||
class="badge {check.listed
|
||||
? 'bg-danger'
|
||||
: check.error
|
||||
? 'bg-dark'
|
||||
: 'bg-success'}"
|
||||
>
|
||||
{check.error
|
||||
? "Error"
|
||||
: check.listed
|
||||
? "Listed"
|
||||
: "Clean"}
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{check.rbl}</code></td>
|
||||
|
|
|
|||
|
|
@ -36,16 +36,28 @@
|
|||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi {contentAnalysis.has_html ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
|
||||
<i
|
||||
class="bi {contentAnalysis.has_html
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-muted'} me-2"
|
||||
></i>
|
||||
<span>HTML Part</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi {contentAnalysis.has_plaintext ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
|
||||
<i
|
||||
class="bi {contentAnalysis.has_plaintext
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-muted'} me-2"
|
||||
></i>
|
||||
<span>Plaintext Part</span>
|
||||
</div>
|
||||
{#if typeof contentAnalysis.has_unsubscribe_link === 'boolean'}
|
||||
{#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi {contentAnalysis.has_unsubscribe_link ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'} me-2"></i>
|
||||
<i
|
||||
class="bi {contentAnalysis.has_unsubscribe_link
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-warning'} me-2"
|
||||
></i>
|
||||
<span>Unsubscribe Link</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -74,7 +86,14 @@
|
|||
<div class="mt-3">
|
||||
<h5>Content Issues</h5>
|
||||
{#each contentAnalysis.html_issues as issue}
|
||||
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
|
||||
<div
|
||||
class="alert alert-{issue.severity === 'critical' ||
|
||||
issue.severity === 'high'
|
||||
? 'danger'
|
||||
: issue.severity === 'medium'
|
||||
? 'warning'
|
||||
: 'info'} py-2 px-3 mb-2"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{issue.type}</strong>
|
||||
|
|
@ -118,11 +137,17 @@
|
|||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {link.status === 'valid' ? 'bg-success' : link.status === 'broken' ? 'bg-danger' : 'bg-warning'}">
|
||||
<span
|
||||
class="badge {link.status === 'valid'
|
||||
? 'bg-success'
|
||||
: link.status === 'broken'
|
||||
? 'bg-danger'
|
||||
: 'bg-warning'}"
|
||||
>
|
||||
{link.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{link.http_code || '-'}</td>
|
||||
<td>{link.http_code || "-"}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
|
@ -146,11 +171,11 @@
|
|||
<tbody>
|
||||
{#each contentAnalysis.images as image}
|
||||
<tr>
|
||||
<td><small class="text-break">{image.src || '-'}</small></td>
|
||||
<td><small class="text-break">{image.src || "-"}</small></td>
|
||||
<td>
|
||||
{#if image.has_alt}
|
||||
<i class="bi bi-check-circle text-success me-1"></i>
|
||||
<small>{image.alt_text || 'Present'}</small>
|
||||
<small>{image.alt_text || "Present"}</small>
|
||||
{:else}
|
||||
<i class="bi bi-x-circle text-warning me-1"></i>
|
||||
<small class="text-muted">Missing</small>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type { DomainAlignment, DnsResults, ReceivedHop } from "$lib/api/types.gen";
|
||||
import type { DnsResults, DomainAlignment, ReceivedHop } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
|
||||
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
|
||||
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
|
||||
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
|
||||
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
||||
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
domainAlignment?: DomainAlignment;
|
||||
|
|
@ -20,7 +20,14 @@
|
|||
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
|
||||
}
|
||||
|
||||
let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain, domainOnly = false }: Props = $props();
|
||||
let {
|
||||
domainAlignment,
|
||||
dnsResults,
|
||||
dnsGrade,
|
||||
dnsScore,
|
||||
receivedChain,
|
||||
domainOnly = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Extract sender IP from first hop
|
||||
const senderIp = $derived(
|
||||
|
|
@ -67,7 +74,10 @@
|
|||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="mb-3 d-flex align-items-center gap-2">
|
||||
<h4 class="mb-0 text-truncate">
|
||||
Received from: <code>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])</code>
|
||||
Received from: <code
|
||||
>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0]
|
||||
.ip}])</code
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -88,10 +98,13 @@
|
|||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h4 class="mb-0 text-truncate">
|
||||
Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||
Return-Path Domain:
|
||||
<code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
|
||||
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain</span>
|
||||
<span class="badge bg-danger ms-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain
|
||||
</span>
|
||||
<small>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<a href="#domain-alignment">See domain alignment</a>
|
||||
|
|
@ -114,10 +127,13 @@
|
|||
{/if}
|
||||
|
||||
<!-- SPF Records (for Return-Path Domain) -->
|
||||
<SpfRecordsDisplay spfRecords={dnsResults.spf_records} dmarcRecord={dnsResults.dmarc_record} />
|
||||
<SpfRecordsDisplay
|
||||
spfRecords={dnsResults.spf_records}
|
||||
dmarcRecord={dnsResults.dmarc_record}
|
||||
/>
|
||||
|
||||
{#if !domainOnly}
|
||||
<hr class="my-4">
|
||||
<hr class="my-4" />
|
||||
|
||||
<!-- From Domain Section -->
|
||||
<div class="mb-3 d-flex align-items-center gap-2">
|
||||
|
|
@ -125,31 +141,34 @@
|
|||
From Domain: <code>{dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain</span>
|
||||
<span class="badge bg-danger ms-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
|
||||
domain
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- MX Records for From Domain -->
|
||||
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
|
||||
<MxRecordsDisplay
|
||||
class="mb-4"
|
||||
mxRecords={dnsResults.from_mx_records}
|
||||
title="Mail Exchange Records for From Domain"
|
||||
description="These MX records handle replies to emails sent from this domain."
|
||||
/>
|
||||
{/if}
|
||||
<!-- MX Records for From Domain -->
|
||||
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
|
||||
<MxRecordsDisplay
|
||||
class="mb-4"
|
||||
mxRecords={dnsResults.from_mx_records}
|
||||
title="Mail Exchange Records for From Domain"
|
||||
description="These MX records handle replies to emails sent from this domain."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !domainOnly}
|
||||
<!-- DKIM Records -->
|
||||
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
|
||||
{/if}
|
||||
|
||||
<!-- DMARC Record -->
|
||||
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
||||
<!-- DMARC Record -->
|
||||
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
||||
|
||||
<!-- BIMI Record -->
|
||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||
<!-- BIMI Record -->
|
||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
interface Props {
|
||||
receivedChain: ReceivedHop[];
|
||||
|
|
@ -9,23 +10,42 @@
|
|||
</script>
|
||||
|
||||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="mb-3" id="email-path">
|
||||
<h5>Email Path (Received Chain)</h5>
|
||||
<div class="list-group">
|
||||
<div class="card shadow-sm" id="email-path">
|
||||
<div
|
||||
class="card-header"
|
||||
class:bg-white={$theme === "light"}
|
||||
class:bg-dark={$theme !== "light"}
|
||||
>
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-pin-map me-2"></i>
|
||||
Email Path
|
||||
</h4>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{#each receivedChain as hop, i}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
|
||||
{hop.reverse || '-'} {#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if} → {hop.by || 'Unknown'}
|
||||
{hop.reverse || "-"}
|
||||
{#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if} → {hop.by ||
|
||||
"Unknown"}
|
||||
</h6>
|
||||
<small class="text-muted" title={hop.timestamp}>{hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'}</small>
|
||||
<small class="text-muted" title={hop.timestamp}>
|
||||
{hop.timestamp
|
||||
? new Intl.DateTimeFormat("default", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(hop.timestamp))
|
||||
: "-"}
|
||||
</small>
|
||||
</div>
|
||||
{#if hop.with || hop.id}
|
||||
{#if hop.with || hop.id || hop.from}
|
||||
<p class="mb-1 small d-flex gap-3">
|
||||
{#if hop.with}
|
||||
<span>
|
||||
<span class="text-muted">Protocol:</span> <code>{hop.with}</code>
|
||||
<span class="text-muted">Protocol:</span>
|
||||
<code>{hop.with}</code>
|
||||
</span>
|
||||
{/if}
|
||||
{#if hop.id}
|
||||
|
|
|
|||
|
|
@ -44,10 +44,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<strong
|
||||
class={getSizeClass(size)}
|
||||
style="color: {getGradeColor(grade)}; font-weight: 700;"
|
||||
>
|
||||
<strong class={getSizeClass(size)} style="color: {getGradeColor(grade)}; font-weight: 700;">
|
||||
{#if grade}
|
||||
{grade}
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { AuthResult, DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
||||
import type { DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
|
@ -38,7 +38,14 @@
|
|||
<div class="mb-3">
|
||||
<h5>Issues</h5>
|
||||
{#each headerAnalysis.issues as issue}
|
||||
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
|
||||
<div
|
||||
class="alert alert-{issue.severity === 'critical' ||
|
||||
issue.severity === 'high'
|
||||
? 'danger'
|
||||
: issue.severity === 'medium'
|
||||
? 'warning'
|
||||
: 'info'} py-2 px-3 mb-2"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{issue.header}</strong>
|
||||
|
|
@ -58,24 +65,48 @@
|
|||
{/if}
|
||||
|
||||
{#if headerAnalysis.domain_alignment}
|
||||
{@const spfStrictAligned = headerAnalysis.domain_alignment.from_domain === headerAnalysis.domain_alignment.return_path_domain}
|
||||
{@const spfRelaxedAligned = headerAnalysis.domain_alignment.from_org_domain === headerAnalysis.domain_alignment.return_path_org_domain}
|
||||
{@const spfStrictAligned =
|
||||
headerAnalysis.domain_alignment.from_domain ===
|
||||
headerAnalysis.domain_alignment.return_path_domain}
|
||||
{@const spfRelaxedAligned =
|
||||
headerAnalysis.domain_alignment.from_org_domain ===
|
||||
headerAnalysis.domain_alignment.return_path_org_domain}
|
||||
<div class="card mb-3" id="domain-alignment">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<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>
|
||||
<i
|
||||
class="bi {headerAnalysis.domain_alignment.aligned
|
||||
? 'bi-check-circle-fill text-success'
|
||||
: headerAnalysis.domain_alignment.relaxed_aligned
|
||||
? 'bi-check-circle text-info'
|
||||
: 'bi-x-circle-fill text-danger'}"
|
||||
></i>
|
||||
Domain Alignment
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small text-muted">
|
||||
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.
|
||||
Domain alignment ensures that the visible "From" domain matches the domain
|
||||
used for authentication (Return-Path or DKIM signature). Proper alignment is
|
||||
crucial for DMARC compliance, regardless of the policy. It helps prevent
|
||||
email spoofing by verifying that the sender domain is consistent across all
|
||||
authentication layers. Only one of the following lines needs to pass.
|
||||
</p>
|
||||
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
|
||||
<div class="mt-3">
|
||||
{#each headerAnalysis.domain_alignment.issues as issue}
|
||||
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
|
||||
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
|
||||
<div
|
||||
class="alert alert-{headerAnalysis.domain_alignment
|
||||
.relaxed_aligned
|
||||
? 'info'
|
||||
: 'warning'} py-2 small mb-2"
|
||||
>
|
||||
<i
|
||||
class="bi bi-{headerAnalysis.domain_alignment
|
||||
.relaxed_aligned
|
||||
? 'info-circle'
|
||||
: 'exclamation-triangle'} me-1"
|
||||
></i>
|
||||
{issue}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -84,7 +115,10 @@
|
|||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<div class="list-group-item d-flex ps-0">
|
||||
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center"
|
||||
style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;"
|
||||
>
|
||||
SPF
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
|
|
@ -92,9 +126,17 @@
|
|||
<div class="col-md-3">
|
||||
<small class="text-muted">Strict Alignment</small>
|
||||
<div>
|
||||
<span class="badge" class:bg-success={spfStrictAligned} class:bg-danger={!spfStrictAligned}>
|
||||
<i class="bi {spfStrictAligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||
<strong>{spfStrictAligned ? 'Pass' : 'Fail'}</strong>
|
||||
<span
|
||||
class="badge"
|
||||
class:bg-success={spfStrictAligned}
|
||||
class:bg-danger={!spfStrictAligned}
|
||||
>
|
||||
<i
|
||||
class="bi {spfStrictAligned
|
||||
? 'bi-check-circle-fill'
|
||||
: 'bi-x-circle-fill'} me-1"
|
||||
></i>
|
||||
<strong>{spfStrictAligned ? "Pass" : "Fail"}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Exact domain match</div>
|
||||
|
|
@ -102,38 +144,78 @@
|
|||
<div class="col-md-3">
|
||||
<small class="text-muted">Relaxed Alignment</small>
|
||||
<div>
|
||||
<span class="badge" class:bg-success={spfRelaxedAligned} class:bg-danger={!spfRelaxedAligned}>
|
||||
<i class="bi {spfRelaxedAligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||
<strong>{spfRelaxedAligned ? 'Pass' : 'Fail'}</strong>
|
||||
<span
|
||||
class="badge"
|
||||
class:bg-success={spfRelaxedAligned}
|
||||
class:bg-danger={!spfRelaxedAligned}
|
||||
>
|
||||
<i
|
||||
class="bi {spfRelaxedAligned
|
||||
? 'bi-check-circle-fill'
|
||||
: 'bi-x-circle-fill'} me-1"
|
||||
></i>
|
||||
<strong>{spfRelaxedAligned ? "Pass" : "Fail"}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Organizational domain match</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Organizational domain match
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">From Domain</small>
|
||||
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
|
||||
<div>
|
||||
<code>
|
||||
{headerAnalysis.domain_alignment.from_domain || "-"}
|
||||
</code>
|
||||
</div>
|
||||
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
|
||||
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
|
||||
<div class="small text-muted mt-1">
|
||||
Org:
|
||||
<code>
|
||||
{headerAnalysis.domain_alignment.from_org_domain}
|
||||
</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Return-Path Domain</small>
|
||||
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
|
||||
<div>
|
||||
<code>
|
||||
{headerAnalysis.domain_alignment.return_path_domain ||
|
||||
"-"}
|
||||
</code>
|
||||
</div>
|
||||
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
|
||||
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
|
||||
<div class="small text-muted mt-1">
|
||||
Org:
|
||||
<code>
|
||||
{headerAnalysis.domain_alignment
|
||||
.return_path_org_domain}
|
||||
</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alignment Information based on DMARC policy -->
|
||||
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
|
||||
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
|
||||
{#if dmarcRecord.spf_alignment === 'strict'}
|
||||
<div
|
||||
class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment ===
|
||||
'strict'
|
||||
? 'alert-warning'
|
||||
: 'alert-info'}"
|
||||
>
|
||||
{#if dmarcRecord.spf_alignment === "strict"}
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
|
||||
<strong>Strict SPF alignment required</strong> — Your DMARC policy
|
||||
requires exact domain match. The Return-Path domain must exactly
|
||||
match the From domain for SPF to pass DMARC alignment.
|
||||
{:else}
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass.
|
||||
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy
|
||||
allows organizational domain matching. As long as both domains
|
||||
share the same organizational domain (e.g., mail.example.com
|
||||
and example.com), SPF alignment can pass.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -141,10 +223,16 @@
|
|||
</div>
|
||||
|
||||
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
|
||||
{@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
|
||||
{@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
|
||||
{@const dkim_aligned =
|
||||
dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
|
||||
{@const dkim_relaxed_aligned =
|
||||
dkim_domain.org_domain ===
|
||||
headerAnalysis.domain_alignment.from_org_domain}
|
||||
<div class="list-group-item d-flex ps-0">
|
||||
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center"
|
||||
style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;"
|
||||
>
|
||||
DKIM
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
|
|
@ -153,35 +241,72 @@
|
|||
<div class="col-md-3">
|
||||
<small class="text-muted">Strict Alignment</small>
|
||||
<div>
|
||||
<span class="badge" class:bg-success={dkim_aligned} class:bg-danger={!dkim_aligned}>
|
||||
<i class="bi {dkim_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||
<strong>{dkim_aligned ? 'Pass' : 'Fail'}</strong>
|
||||
<span
|
||||
class="badge"
|
||||
class:bg-success={dkim_aligned}
|
||||
class:bg-danger={!dkim_aligned}
|
||||
>
|
||||
<i
|
||||
class="bi {dkim_aligned
|
||||
? 'bi-check-circle-fill'
|
||||
: 'bi-x-circle-fill'} me-1"
|
||||
></i>
|
||||
<strong>{dkim_aligned ? "Pass" : "Fail"}</strong
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Exact domain match</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Exact domain match
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Relaxed Alignment</small>
|
||||
<div>
|
||||
<span class="badge" class:bg-success={dkim_relaxed_aligned} class:bg-danger={!dkim_relaxed_aligned}>
|
||||
<i class="bi {dkim_relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||
<strong>{dkim_relaxed_aligned ? 'Pass' : 'Fail'}</strong>
|
||||
<span
|
||||
class="badge"
|
||||
class:bg-success={dkim_relaxed_aligned}
|
||||
class:bg-danger={!dkim_relaxed_aligned}
|
||||
>
|
||||
<i
|
||||
class="bi {dkim_relaxed_aligned
|
||||
? 'bi-check-circle-fill'
|
||||
: 'bi-x-circle-fill'} me-1"
|
||||
></i>
|
||||
<strong
|
||||
>{dkim_relaxed_aligned
|
||||
? "Pass"
|
||||
: "Fail"}</strong
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Organizational domain match</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Organizational domain match
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">From Domain</small>
|
||||
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
|
||||
<div>
|
||||
<code
|
||||
>{headerAnalysis.domain_alignment.from_domain ||
|
||||
"-"}</code
|
||||
>
|
||||
</div>
|
||||
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
|
||||
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
|
||||
<div class="small text-muted mt-1">
|
||||
Org: <code
|
||||
>{headerAnalysis.domain_alignment
|
||||
.from_org_domain}</code
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Signature Domain</small>
|
||||
<div><code>{dkim_domain.domain || '-'}</code></div>
|
||||
<div><code>{dkim_domain.domain || "-"}</code></div>
|
||||
{#if dkim_domain.domain !== dkim_domain.org_domain}
|
||||
<div class="small text-muted mt-1">Org: <code>{dkim_domain.org_domain}</code></div>
|
||||
<div class="small text-muted mt-1">
|
||||
Org: <code>{dkim_domain.org_domain}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,13 +314,25 @@
|
|||
<!-- Alignment Information based on DMARC policy -->
|
||||
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
|
||||
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
|
||||
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
|
||||
{#if dmarcRecord.dkim_alignment === 'strict'}
|
||||
<div
|
||||
class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment ===
|
||||
'strict'
|
||||
? 'alert-warning'
|
||||
: 'alert-info'}"
|
||||
>
|
||||
{#if dmarcRecord.dkim_alignment === "strict"}
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Strict DKIM alignment required</strong> — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment.
|
||||
<strong>Strict DKIM alignment required</strong> —
|
||||
Your DMARC policy requires exact domain match. The
|
||||
DKIM signature domain must exactly match the From
|
||||
domain for DKIM to pass DMARC alignment.
|
||||
{:else}
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Relaxed DKIM alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass.
|
||||
<strong>Relaxed DKIM alignment allowed</strong> —
|
||||
Your DMARC policy allows organizational domain matching.
|
||||
As long as both domains share the same organizational
|
||||
domain (e.g., mail.example.com and example.com),
|
||||
DKIM alignment can pass.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -224,9 +361,9 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(headerAnalysis.headers).sort((a, b) => {
|
||||
const importanceOrder = { 'required': 0, 'recommended': 1, 'optional': 2, 'newsletter': 3 };
|
||||
const aImportance = importanceOrder[a[1].importance || 'optional'];
|
||||
const bImportance = importanceOrder[b[1].importance || 'optional'];
|
||||
const importanceOrder = { required: 0, recommended: 1, optional: 2, newsletter: 3 };
|
||||
const aImportance = importanceOrder[a[1].importance || "optional"];
|
||||
const bImportance = importanceOrder[b[1].importance || "optional"];
|
||||
return aImportance - bImportance;
|
||||
}) as [name, check]}
|
||||
<tr>
|
||||
|
|
@ -235,23 +372,39 @@
|
|||
</td>
|
||||
<td>
|
||||
{#if check.importance}
|
||||
<small class="text-{check.importance === 'required' ? 'danger' : check.importance === 'recommended' ? 'warning' : 'secondary'}">
|
||||
<small
|
||||
class="text-{check.importance === 'required'
|
||||
? 'danger'
|
||||
: check.importance === 'recommended'
|
||||
? 'warning'
|
||||
: 'secondary'}"
|
||||
>
|
||||
{check.importance}
|
||||
</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<i class="bi {check.present ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'}"></i>
|
||||
<i
|
||||
class="bi {check.present
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-danger'}"
|
||||
></i>
|
||||
</td>
|
||||
<td>
|
||||
{#if check.present && check.valid !== undefined}
|
||||
<i class="bi {check.valid ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'}"></i>
|
||||
<i
|
||||
class="bi {check.valid
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-warning'}"
|
||||
></i>
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted text-truncate" title={check.value}>{check.value || '-'}</small>
|
||||
<small class="text-muted text-truncate" title={check.value}
|
||||
>{check.value || "-"}</small
|
||||
>
|
||||
{#if check.issues && check.issues.length > 0}
|
||||
{#each check.issues as issue}
|
||||
<div class="text-warning small">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
|
||||
import type { MxRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@
|
|||
);
|
||||
|
||||
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
|
||||
|
||||
let showDifferent = $state(false);
|
||||
const differentCount = $derived(
|
||||
ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if ptrRecords && ptrRecords.length > 0}
|
||||
|
|
@ -63,15 +68,31 @@
|
|||
<div class="mb-2">
|
||||
<strong>Forward Resolution (A/AAAA):</strong>
|
||||
{#each ptrForwardRecords as ip}
|
||||
<div class="d-flex gap-2 align-items-center mt-1">
|
||||
{#if senderIp && ip === senderIp}
|
||||
<span class="badge bg-success">Match</span>
|
||||
{:else}
|
||||
<span class="badge bg-warning">Different</span>
|
||||
{/if}
|
||||
<code>{ip}</code>
|
||||
</div>
|
||||
{#if ip === senderIp || !fcrDnsIsValid || showDifferent}
|
||||
<div class="d-flex gap-2 align-items-center mt-1">
|
||||
{#if senderIp && ip === senderIp}
|
||||
<span class="badge bg-success">Match</span>
|
||||
{:else}
|
||||
<span class="badge bg-secondary">Different</span>
|
||||
{/if}
|
||||
<code>{ip}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if fcrDnsIsValid && differentCount > 0}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="btn btn-link btn-sm p-0 text-muted"
|
||||
onclick={() => (showDifferent = !showDifferent)}
|
||||
>
|
||||
{#if showDifferent}
|
||||
Hide other IPs
|
||||
{:else}
|
||||
Show {differentCount} other IP{differentCount > 1 ? 's' : ''} (not the sender)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if fcrDnsIsValid}
|
||||
<div class="alert alert-success mb-0 mt-2">
|
||||
|
|
|
|||
146
web/src/lib/components/RspamdCard.svelte
Normal file
146
web/src/lib/components/RspamdCard.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<script lang="ts">
|
||||
import type { RspamdResult } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
rspamd: RspamdResult;
|
||||
}
|
||||
|
||||
let { rspamd }: Props = $props();
|
||||
|
||||
// Derive effective action from score vs known rspamd default thresholds.
|
||||
// The action header is unreliable in milter setups (always "no action").
|
||||
const RSPAMD_GREYLIST_THRESHOLD = 4;
|
||||
const RSPAMD_ADD_HEADER_THRESHOLD = 6;
|
||||
|
||||
const effectiveAction = $derived.by(() => {
|
||||
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
|
||||
if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" };
|
||||
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
|
||||
return { label: "Add header", cls: "bg-warning text-dark" };
|
||||
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
||||
return { label: "Greylist", cls: "bg-warning text-dark" };
|
||||
return { label: "No action", cls: "bg-success" };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="rspamd-details">
|
||||
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-bug me-2"></i>
|
||||
rspamd Analysis
|
||||
</span>
|
||||
<span>
|
||||
{#if rspamd.deliverability_score !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(rspamd.deliverability_score)}">
|
||||
{rspamd.deliverability_score}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if rspamd.deliverability_grade !== undefined}
|
||||
<GradeDisplay grade={rspamd.deliverability_grade} size="small" />
|
||||
{/if}
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<strong>Score:</strong>
|
||||
<span class={rspamd.is_spam ? "text-danger" : "text-success"}>
|
||||
{rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Classified as:</strong>
|
||||
<span class="badge {rspamd.is_spam ? 'bg-danger' : 'bg-success'} ms-2">
|
||||
{rspamd.is_spam ? "SPAM" : "HAM"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Action:</strong>
|
||||
<span class="badge {effectiveAction.cls} ms-2">
|
||||
{effectiveAction.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0}
|
||||
<div class="mb-3">
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th class="text-end">Score</th>
|
||||
<th>Parameters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]}
|
||||
<tr
|
||||
class={symbol.score > 0
|
||||
? "table-warning"
|
||||
: symbol.score < 0
|
||||
? "table-success"
|
||||
: ""}
|
||||
>
|
||||
<td class="font-monospace">{symbolName}</td>
|
||||
<td class="text-end">
|
||||
<span
|
||||
class={symbol.score > 0
|
||||
? "text-danger fw-bold"
|
||||
: symbol.score < 0
|
||||
? "text-success fw-bold"
|
||||
: "text-muted"}
|
||||
>
|
||||
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-muted">{symbol.params ?? ""}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rspamd.report}
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
||||
<pre
|
||||
class="mt-2 small {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} p-3 rounded">{rspamd.report}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details summary {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
details summary:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Darker table colors in dark mode */
|
||||
:global([data-bs-theme="dark"]) .table-warning {
|
||||
--bs-table-bg: rgba(255, 193, 7, 0.2);
|
||||
--bs-table-border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
:global([data-bs-theme="dark"]) .table-success {
|
||||
--bs-table-bg: rgba(25, 135, 84, 0.2);
|
||||
--bs-table-border-color: rgba(25, 135, 84, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { ScoreSummary } from "$lib/api/types.gen";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
grade: string;
|
||||
|
|
@ -58,13 +58,10 @@
|
|||
<a href="#dns-details" class="text-decoration-none">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay
|
||||
grade={summary.dns_grade}
|
||||
score={summary.dns_score}
|
||||
/>
|
||||
<GradeDisplay grade={summary.dns_grade} score={summary.dns_score} />
|
||||
<small class="text-muted d-block">DNS</small>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -73,8 +70,8 @@
|
|||
<a href="#authentication-details" class="text-decoration-none">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay
|
||||
grade={summary.authentication_grade}
|
||||
|
|
@ -88,8 +85,8 @@
|
|||
<a href="#rbl-details" class="text-decoration-none">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay
|
||||
grade={summary.blacklist_grade}
|
||||
|
|
@ -103,8 +100,8 @@
|
|||
<a href="#header-details" class="text-decoration-none">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay
|
||||
grade={summary.header_grade}
|
||||
|
|
@ -118,13 +115,10 @@
|
|||
<a href="#spam-details" class="text-decoration-none">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay
|
||||
grade={summary.spam_grade}
|
||||
score={summary.spam_score}
|
||||
/>
|
||||
<GradeDisplay grade={summary.spam_grade} score={summary.spam_score} />
|
||||
<small class="text-muted d-block">Spam Score</small>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -133,8 +127,8 @@
|
|||
<a href="#content-details" class="text-decoration-none">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay
|
||||
grade={summary.content_grade}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@
|
|||
|
||||
interface Props {
|
||||
spamassassin: SpamAssassinResult;
|
||||
spamGrade?: string;
|
||||
spamScore?: number;
|
||||
}
|
||||
|
||||
let { spamassassin, spamGrade, spamScore }: Props = $props();
|
||||
let { spamassassin }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="spam-details">
|
||||
|
|
@ -21,13 +19,13 @@
|
|||
SpamAssassin Analysis
|
||||
</span>
|
||||
<span>
|
||||
{#if spamScore !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(spamScore)}">
|
||||
{spamScore}%
|
||||
{#if spamassassin.deliverability_score !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(spamassassin.deliverability_score)}">
|
||||
{spamassassin.deliverability_score}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if spamGrade !== undefined}
|
||||
<GradeDisplay grade={spamGrade} size="small" />
|
||||
{#if spamassassin.deliverability_grade !== undefined}
|
||||
<GradeDisplay grade={spamassassin.deliverability_grade} size="small" />
|
||||
{/if}
|
||||
</span>
|
||||
</h4>
|
||||
|
|
@ -61,14 +59,26 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(spamassassin.test_details) as [testName, detail]}
|
||||
<tr class={detail.score > 0 ? 'table-warning' : detail.score < 0 ? 'table-success' : ''}>
|
||||
<tr
|
||||
class={detail.score > 0
|
||||
? "table-warning"
|
||||
: detail.score < 0
|
||||
? "table-success"
|
||||
: ""}
|
||||
>
|
||||
<td class="font-monospace">{testName}</td>
|
||||
<td class="text-end">
|
||||
<span class={detail.score > 0 ? 'text-danger fw-bold' : detail.score < 0 ? 'text-success fw-bold' : 'text-muted'}>
|
||||
{detail.score > 0 ? '+' : ''}{detail.score.toFixed(1)}
|
||||
<span
|
||||
class={detail.score > 0
|
||||
? "text-danger fw-bold"
|
||||
: detail.score < 0
|
||||
? "text-success fw-bold"
|
||||
: "text-muted"}
|
||||
>
|
||||
{detail.score > 0 ? "+" : ""}{detail.score.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">{detail.description || ''}</td>
|
||||
<td class="small">{detail.description || ""}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
|
@ -80,7 +90,11 @@
|
|||
<strong>Tests Triggered:</strong>
|
||||
<div class="mt-2">
|
||||
{#each spamassassin.tests as test}
|
||||
<span class="badge {$theme === 'light' ? 'bg-light text-dark' : 'bg-secondary'} me-1 mb-1">{test}</span>
|
||||
<span
|
||||
class="badge {$theme === 'light'
|
||||
? 'bg-light text-dark'
|
||||
: 'bg-secondary'} me-1 mb-1">{test}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -89,7 +103,10 @@
|
|||
{#if spamassassin.report}
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
||||
<pre class="mt-2 small {$theme === 'light' ? 'bg-light' : 'bg-secondary'} p-3 rounded">{spamassassin.report}</pre>
|
||||
<pre
|
||||
class="mt-2 small {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} p-3 rounded">{spamassassin.report}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
// Check if DMARC has strict policy (quarantine or reject)
|
||||
const dmarcStrict = $derived(
|
||||
dmarcRecord?.valid &&
|
||||
dmarcRecord?.policy &&
|
||||
(dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject")
|
||||
dmarcRecord?.policy &&
|
||||
(dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject"),
|
||||
);
|
||||
|
||||
// Compute overall validity
|
||||
|
|
@ -43,7 +43,11 @@
|
|||
<span class="badge bg-secondary">SPF</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small text-muted mb-0">SPF specifies which mail servers are authorized to send emails on behalf of your domain. Receiving servers check the sender's IP address against your SPF record to prevent email spoofing.</p>
|
||||
<p class="card-text small text-muted mb-0">
|
||||
SPF specifies which mail servers are authorized to send emails on behalf of your
|
||||
domain. Receiving servers check the sender's IP address against your SPF record to
|
||||
prevent email spoofing.
|
||||
</p>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{#each spfRecords as spf, index}
|
||||
|
|
@ -76,18 +80,31 @@
|
|||
{:else if spf.all_qualifier === "?"}
|
||||
<span class="badge bg-warning">Neutral (?all)</span>
|
||||
{/if}
|
||||
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))}
|
||||
<div class="alert small mt-2" class:alert-warning={spf.all_qualifier !== '-'} class:alert-success={spf.all_qualifier === '-'}>
|
||||
{#if spf.all_qualifier === '-'}
|
||||
All unauthorized servers will be rejected. This is the recommended strict policy.
|
||||
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes("redirect="))}
|
||||
<div
|
||||
class="alert small mt-2"
|
||||
class:alert-warning={spf.all_qualifier !== "-"}
|
||||
class:alert-success={spf.all_qualifier === "-"}
|
||||
>
|
||||
{#if spf.all_qualifier === "-"}
|
||||
All unauthorized servers will be rejected. This is the
|
||||
recommended strict policy.
|
||||
{:else if dmarcStrict}
|
||||
While your DMARC {dmarcRecord?.policy} policy provides some protection, consider using <code>-all</code> for better security with some old mailbox providers.
|
||||
{:else if spf.all_qualifier === '~'}
|
||||
Unauthorized servers will softfail. Consider using <code>-all</code> for stricter policy, though this rarely affects legitimate email deliverability.
|
||||
{:else if spf.all_qualifier === '+'}
|
||||
All servers are allowed to send email. This severely weakens email authentication. Use <code>-all</code> for strict policy.
|
||||
{:else if spf.all_qualifier === '?'}
|
||||
No statement about unauthorized servers. Use <code>-all</code> for strict policy to prevent spoofing.
|
||||
While your DMARC {dmarcRecord?.policy} policy provides some protection,
|
||||
consider using <code>-all</code> for better security with some
|
||||
old mailbox providers.
|
||||
{:else if spf.all_qualifier === "~"}
|
||||
Unauthorized servers will softfail. Consider using <code
|
||||
>-all</code
|
||||
> for stricter policy, though this rarely affects legitimate
|
||||
email deliverability.
|
||||
{:else if spf.all_qualifier === "+"}
|
||||
All servers are allowed to send email. This severely weakens
|
||||
email authentication. Use <code>-all</code> for strict policy.
|
||||
{:else if spf.all_qualifier === "?"}
|
||||
No statement about unauthorized servers. Use <code
|
||||
>-all</code
|
||||
> for strict policy to prevent spoofing.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -95,14 +112,16 @@
|
|||
{/if}
|
||||
{#if spf.record}
|
||||
<div class="mb-2">
|
||||
<strong>Record:</strong><br>
|
||||
<strong>Record:</strong><br />
|
||||
<code class="d-block mt-1 text-break">{spf.record}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if spf.error}
|
||||
<div class="alert alert-{spf.valid ? 'warning' : 'danger'} mb-0 mt-2">
|
||||
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"></i>
|
||||
<strong>{spf.valid ? 'Warning:' : 'Error:'}</strong> {spf.error}
|
||||
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"
|
||||
></i>
|
||||
<strong>{spf.valid ? "Warning:" : "Error:"}</strong>
|
||||
{spf.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@
|
|||
} else if (spfResult === "temperror" || spfResult === "permerror") {
|
||||
segments.push({
|
||||
text: "encountered an error",
|
||||
highlight: { color: "warning", bold: true },
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#authentication-spf",
|
||||
});
|
||||
segments.push({ text: ", check your SPF record configuration" });
|
||||
|
|
@ -318,7 +318,9 @@
|
|||
// BIMI
|
||||
const bimiResult = report.authentication?.bimi;
|
||||
if (
|
||||
(dmarcRecord && dmarcRecord.valid && dmarcRecord.policy != "none") &&
|
||||
dmarcRecord &&
|
||||
dmarcRecord.valid &&
|
||||
dmarcRecord.policy != "none" &&
|
||||
(!bimiResult || bimiResult.result !== "skipped")
|
||||
) {
|
||||
const bimiRecord = report.dns_results?.bimi_record;
|
||||
|
|
@ -329,7 +331,7 @@
|
|||
highlight: { color: "good", bold: true },
|
||||
link: "#dns-bimi",
|
||||
});
|
||||
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
|
||||
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
|
||||
segments.push({ text: " declined to participate" });
|
||||
} else if (bimiResult?.result === "fail") {
|
||||
segments.push({ text: " but " });
|
||||
|
|
@ -420,6 +422,17 @@
|
|||
});
|
||||
}
|
||||
|
||||
// One-click unsubscribe check
|
||||
const unsubscribeMethods = report.content_analysis?.unsubscribe_methods;
|
||||
if (unsubscribeMethods && unsubscribeMethods.length > 0 && !unsubscribeMethods.includes("one-click")) {
|
||||
segments.push({ text: ". This email could benefit from " });
|
||||
segments.push({
|
||||
text: "one-click unsubscribe",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#content-details",
|
||||
});
|
||||
}
|
||||
|
||||
// Content/spam assessment
|
||||
const spamAssassin = report.spamassassin;
|
||||
const contentScore = report.summary?.content_score || 0;
|
||||
|
|
@ -523,19 +536,39 @@
|
|||
{#if segment.link}
|
||||
<a
|
||||
href={segment.link}
|
||||
class="summary-link {segment.highlight ? getColorClass(segment.highlight.color) : ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
|
||||
class="summary-link {segment.highlight
|
||||
? getColorClass(segment.highlight.color)
|
||||
: ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight
|
||||
?.emphasis
|
||||
? 'fst-italic'
|
||||
: ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
|
||||
>
|
||||
{segment.text}
|
||||
</a>
|
||||
{:else if segment.highlight}
|
||||
<span class="{getColorClass(segment.highlight.color)} {segment.highlight.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}">
|
||||
<span
|
||||
class="{getColorClass(segment.highlight.color)} {segment.highlight.bold
|
||||
? 'highlighted'
|
||||
: ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment
|
||||
.highlight?.monospace
|
||||
? 'font-monospace'
|
||||
: ''}"
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
Overall, your email received a grade <GradeDisplay grade={report.grade} score={report.score} size="inline" />{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}: you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}: you could have delivery issues with common providers.{:else if report.grade == "F"}: it will most likely be rejected by most providers.{:else}!{/if} Check the details below 🔽
|
||||
Overall, your email received a grade <GradeDisplay
|
||||
grade={report.grade}
|
||||
score={report.score}
|
||||
size="inline"
|
||||
/>{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}:
|
||||
you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}:
|
||||
you could have delivery issues with common providers.{:else if report.grade == "F"}:
|
||||
it will most likely be rejected by most providers.{:else}!{/if} Check the details below
|
||||
🔽
|
||||
</p>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
|
|
|||
62
web/src/lib/components/WhitelistCard.svelte
Normal file
62
web/src/lib/components/WhitelistCard.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
interface Props {
|
||||
whitelists: Record<string, BlacklistCheck[]>;
|
||||
}
|
||||
|
||||
let { whitelists }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="dnswl-details">
|
||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-shield-check me-2"></i>
|
||||
Whitelist Checks
|
||||
</span>
|
||||
<span class="badge bg-info text-white">Informational</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
DNS whitelists identify trusted senders. Being listed here is a positive signal, but has
|
||||
no impact on the overall score.
|
||||
</p>
|
||||
|
||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
||||
{#each Object.entries(whitelists) as [ip, checks]}
|
||||
<div class="col mb-3">
|
||||
<h5 class="text-muted">
|
||||
<i class="bi bi-hdd-network me-1"></i>
|
||||
{ip}
|
||||
</h5>
|
||||
<table class="table table-sm table-striped table-hover mb-0">
|
||||
<tbody>
|
||||
{#each checks as check}
|
||||
<tr>
|
||||
<td title={check.response || "-"}>
|
||||
<span
|
||||
class="badge"
|
||||
class:bg-success={check.listed}
|
||||
class:bg-dark={check.error}
|
||||
class:bg-secondary={!check.listed && !check.error}
|
||||
>
|
||||
{check.error
|
||||
? "Error"
|
||||
: check.listed
|
||||
? "Listed"
|
||||
: "Not listed"}
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{check.rbl}</code></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,25 +1,27 @@
|
|||
// Component exports
|
||||
export { default as FeatureCard } from "./FeatureCard.svelte";
|
||||
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
|
||||
export { default as ScoreCard } from "./ScoreCard.svelte";
|
||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
|
||||
export { default as PendingState } from "./PendingState.svelte";
|
||||
export { default as AuthenticationCard } from "./AuthenticationCard.svelte";
|
||||
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
|
||||
export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte";
|
||||
export { default as BlacklistCard } from "./BlacklistCard.svelte";
|
||||
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
|
||||
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
|
||||
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
|
||||
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
|
||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||
export { default as 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 DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
|
||||
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
|
||||
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
|
||||
export { default as EmailPathCard } from "./EmailPathCard.svelte";
|
||||
export { default as ErrorDisplay } from "./ErrorDisplay.svelte";
|
||||
export { default as FeatureCard } from "./FeatureCard.svelte";
|
||||
export { default as GradeDisplay } from "./GradeDisplay.svelte";
|
||||
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
|
||||
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
|
||||
export { default as Logo } from "./Logo.svelte";
|
||||
export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte";
|
||||
export { default as PendingState } from "./PendingState.svelte";
|
||||
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
|
||||
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
|
||||
export { default as ScoreCard } from "./ScoreCard.svelte";
|
||||
export { default as RspamdCard } from "./RspamdCard.svelte";
|
||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { writable } from "svelte/store";
|
|||
interface AppConfig {
|
||||
report_retention?: number;
|
||||
survey_url?: string;
|
||||
custom_logo_url?: string;
|
||||
rbls?: string[];
|
||||
}
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
import { writable } from "svelte/store";
|
||||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { browser } from "$app/environment";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const getInitialTheme = () => {
|
||||
if (!browser) return "light";
|
||||
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored) return stored;
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import { ErrorDisplay } from "$lib/components";
|
||||
|
||||
let status = $derived($page.status);
|
||||
let message = $derived($page.error?.message || "An unexpected error occurred");
|
||||
let status = $derived(page.status);
|
||||
let message = $derived(page.error?.message || "An unexpected error occurred");
|
||||
|
||||
function getErrorTitle(status: number): string {
|
||||
switch (status) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "../app.css";
|
||||
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
|
||||
import Logo from "$lib/components/Logo.svelte";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
|
|
@ -25,15 +26,19 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-vh-100 d-flex flex-column">
|
||||
<nav class="navbar navbar-expand-lg navbar-light shadow-sm">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="/">
|
||||
<i class="bi bi-envelope-check me-2"></i>
|
||||
<Logo color={$theme === "light" ? "black" : "white"} />
|
||||
{#if $appConfig.custom_logo_url}
|
||||
<img src={$appConfig.custom_logo_url} alt="Logo" style="height: 25px;" />
|
||||
{:else}
|
||||
<i class="bi bi-envelope-check me-2"></i>
|
||||
<Logo color={$theme === "light" ? "black" : "white"} />
|
||||
{/if}
|
||||
</a>
|
||||
<div>
|
||||
<span class="d-none d-md-inline navbar-text text-primary small">
|
||||
|
|
@ -55,7 +60,26 @@
|
|||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
<footer class="pt-3 pb-2 bg-dark text-light">
|
||||
<footer
|
||||
id="footer-classic"
|
||||
class="px-4 px-md-5 py-2 bg-tertiary d-flex justify-content-between"
|
||||
>
|
||||
<a href="https://happydeliver.org/" target="_blank">Powered by happyDeliver</a>
|
||||
<ul class="d-flex footer-nav">
|
||||
<li>
|
||||
<a
|
||||
href="https://git.happydomain.org/happydeliver/-/blob/master/api/openapi.yaml?ref_type=heads"
|
||||
target="_blank"
|
||||
>
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="https://git.happydomain.org/happydeliver" target="_blank">Git</a></li>
|
||||
<li><a href="https://feedback.happydeliver.org/" target="_blank">Feedback</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
<footer id="footer-happydomain" class="d-none pt-3 pb-2 bg-dark text-light">
|
||||
<div class="container mb-4">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4">
|
||||
<div class="col">
|
||||
|
|
@ -144,6 +168,27 @@
|
|||
border-top: 3px solid #9332bb;
|
||||
}
|
||||
|
||||
footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer-nav li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-nav li:not(:last-child)::after {
|
||||
content: "|";
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
@ -155,7 +200,6 @@
|
|||
|
||||
.footer-links a {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import { createTest as apiCreateTest } from "$lib/api";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { FeatureCard, HowItWorksStep } from "$lib/components";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@
|
|||
}
|
||||
|
||||
// Basic IPv4/IPv6 validation
|
||||
const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||
const ipv4Pattern =
|
||||
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv6Pattern =
|
||||
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||
|
||||
if (!ipv4Pattern.test(ip.trim()) && !ipv6Pattern.test(ip.trim())) {
|
||||
error = "Please enter a valid IPv4 or IPv6 address (e.g., 192.0.2.1)";
|
||||
|
|
@ -48,7 +50,8 @@
|
|||
Check IP Blacklist Status
|
||||
</h1>
|
||||
<p class="lead text-muted">
|
||||
Test an IP address against multiple DNS-based blacklists (RBLs) to check its reputation.
|
||||
Test an IP address against multiple DNS-based blacklists (RBLs) to check its
|
||||
reputation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -103,7 +106,9 @@
|
|||
</h3>
|
||||
<ul class="list-unstyled mb-0 small">
|
||||
{#each $appConfig.rbls as rbl}
|
||||
<li class="mb-2"><i class="bi bi-arrow-right me-2"></i>{rbl}</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-arrow-right me-2"></i>{rbl}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -118,7 +123,9 @@
|
|||
Why Check Blacklists?
|
||||
</h3>
|
||||
<p class="small mb-2">
|
||||
DNS-based blacklists (RBLs) are used by email servers to identify and block spam sources. Being listed can severely impact email deliverability.
|
||||
DNS-based blacklists (RBLs) are used by email servers to identify
|
||||
and block spam sources. Being listed can severely impact email
|
||||
deliverability.
|
||||
</p>
|
||||
<p class="small mb-3">
|
||||
This tool checks your IP against multiple popular RBLs to help you:
|
||||
|
|
@ -128,7 +135,8 @@
|
|||
<i class="bi bi-arrow-right me-2"></i>Monitor IP reputation
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-arrow-right me-2"></i>Identify deliverability issues
|
||||
<i class="bi bi-arrow-right me-2"></i>Identify deliverability
|
||||
issues
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-arrow-right me-2"></i>Take corrective action
|
||||
|
|
@ -146,7 +154,8 @@
|
|||
Need Complete Email Analysis?
|
||||
</h3>
|
||||
<p class="small mb-2">
|
||||
For comprehensive deliverability testing including DKIM verification, content analysis, spam scoring, and more:
|
||||
For comprehensive deliverability testing including DKIM verification, content
|
||||
analysis, spam scoring, and more:
|
||||
</p>
|
||||
<a href="/" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-envelope-plus me-1"></i>
|
||||
|
|
@ -159,7 +168,9 @@
|
|||
|
||||
<style>
|
||||
.card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { checkBlacklist } from "$lib/api";
|
||||
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
||||
import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
|
||||
import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
let ip = $derived($page.params.ip);
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
});
|
||||
|
||||
if (response.response.ok) {
|
||||
result = response.data;
|
||||
result = response.data ?? null;
|
||||
} else if (response.error) {
|
||||
error = response.error.message || "Failed to check IP address";
|
||||
}
|
||||
|
|
@ -80,7 +80,8 @@
|
|||
<!-- Error State -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"
|
||||
></i>
|
||||
<h3 class="h4 mt-4">Check Failed</h3>
|
||||
<p class="text-muted mb-4">{error}</p>
|
||||
<button class="btn btn-primary" onclick={analyzeIP}>
|
||||
|
|
@ -98,22 +99,33 @@
|
|||
<div class="row align-items-center">
|
||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
||||
<h2 class="h2 mb-2">
|
||||
<span class="font-monospace text-truncate">{result.ip}</span>
|
||||
<span class="font-monospace text-truncate">{result.ip}</span
|
||||
>
|
||||
</h2>
|
||||
{#if result.listed_count === 0}
|
||||
<div class="alert alert-success mb-0 d-inline-block">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
<strong>Not Listed</strong>
|
||||
<p class="d-inline mb-0 mt-1 small">
|
||||
This IP address is not listed on any checked blacklists.
|
||||
This IP address is not listed on any checked
|
||||
blacklists.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-danger mb-0 d-inline-block">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Listed on {result.listed_count} blacklist{result.listed_count > 1 ? "s" : ""}</strong>
|
||||
<strong
|
||||
>Listed on {result.listed_count} blacklist{result.listed_count >
|
||||
1
|
||||
? "s"
|
||||
: ""}</strong
|
||||
>
|
||||
<p class="mb-0 mt-1 small">
|
||||
This IP address is listed on {result.listed_count} of {result.checks.length} checked blacklist{result.checks.length > 1 ? "s" : ""}.
|
||||
This IP address is listed on {result.listed_count} of
|
||||
{result.blacklists.length} checked blacklist{result
|
||||
.blacklists.length > 1
|
||||
? "s"
|
||||
: ""}.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -121,8 +133,8 @@
|
|||
<div class="offset-md-3 col-md-3 text-center">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay score={result.score} grade={result.grade} />
|
||||
<small class="text-muted d-block">Blacklist Score</small>
|
||||
|
|
@ -138,12 +150,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blacklist Results Card -->
|
||||
<BlacklistCard
|
||||
blacklists={{ [result.ip]: result.checks }}
|
||||
blacklistScore={result.score}
|
||||
blacklistGrade={result.grade}
|
||||
/>
|
||||
<div class="row">
|
||||
<!-- Blacklist Results Card -->
|
||||
<div class="col col-lg-6">
|
||||
<BlacklistCard
|
||||
blacklists={{ [result.ip]: result.blacklists }}
|
||||
blacklistScore={result.score}
|
||||
blacklistGrade={result.grade}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Results Card -->
|
||||
{#if result.whitelists && result.whitelists.length > 0}
|
||||
<div class="col col-lg-6">
|
||||
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Information Card -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
|
|
@ -154,23 +177,36 @@
|
|||
</h3>
|
||||
{#if result.listed_count === 0}
|
||||
<p class="mb-3">
|
||||
<strong>Good news!</strong> This IP address is not currently listed on any of the
|
||||
checked DNS-based blacklists (RBLs). This indicates a good sender reputation
|
||||
and should not negatively impact email deliverability.
|
||||
<strong>Good news!</strong> This IP address is not currently listed
|
||||
on any of the checked DNS-based blacklists (RBLs). This indicates
|
||||
a good sender reputation and should not negatively impact email deliverability.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mb-3">
|
||||
<strong>Warning:</strong> This IP address is listed on {result.listed_count} blacklist{result.listed_count > 1 ? "s" : ""}.
|
||||
Being listed can significantly impact email deliverability as many mail servers
|
||||
<strong>Warning:</strong> This IP address is listed on {result.listed_count}
|
||||
blacklist{result.listed_count > 1 ? "s" : ""}. Being listed can
|
||||
significantly impact email deliverability as many mail servers
|
||||
use these blacklists to filter incoming mail.
|
||||
</p>
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="h6 mb-2">Recommended Actions:</h4>
|
||||
<ul class="mb-0 small">
|
||||
<li>Investigate the cause of the listing (compromised system, spam complaints, etc.)</li>
|
||||
<li>Fix any security issues or stop sending practices that led to the listing</li>
|
||||
<li>Request delisting from each RBL (check their websites for removal procedures)</li>
|
||||
<li>Monitor your IP reputation regularly to prevent future listings</li>
|
||||
<li>
|
||||
Investigate the cause of the listing (compromised
|
||||
system, spam complaints, etc.)
|
||||
</li>
|
||||
<li>
|
||||
Fix any security issues or stop sending practices that
|
||||
led to the listing
|
||||
</li>
|
||||
<li>
|
||||
Request delisting from each RBL (check their websites
|
||||
for removal procedures)
|
||||
</li>
|
||||
<li>
|
||||
Monitor your IP reputation regularly to prevent future
|
||||
listings
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -186,8 +222,8 @@
|
|||
</h3>
|
||||
<p class="mb-3">
|
||||
This blacklist check tests IP reputation only. For comprehensive
|
||||
deliverability testing including DKIM verification, content analysis,
|
||||
spam scoring, and DNS configuration:
|
||||
deliverability testing including DKIM verification, content
|
||||
analysis, spam scoring, and DNS configuration:
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">
|
||||
<i class="bi bi-envelope-plus me-2"></i>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
}
|
||||
|
||||
// Basic domain validation
|
||||
const domainPattern = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*$/;
|
||||
const domainPattern =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*$/;
|
||||
if (!domainPattern.test(domain.trim())) {
|
||||
error = "Please enter a valid domain name (e.g., example.com)";
|
||||
return;
|
||||
|
|
@ -99,10 +100,18 @@
|
|||
What's Checked
|
||||
</h3>
|
||||
<ul class="list-unstyled mb-0 small">
|
||||
<li class="mb-2"><i class="bi bi-arrow-right me-2"></i>MX Records</li>
|
||||
<li class="mb-2"><i class="bi bi-arrow-right me-2"></i>SPF Records</li>
|
||||
<li class="mb-2"><i class="bi bi-arrow-right me-2"></i>DMARC Policy</li>
|
||||
<li class="mb-2"><i class="bi bi-arrow-right me-2"></i>BIMI Support</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-arrow-right me-2"></i>MX Records
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-arrow-right me-2"></i>SPF Records
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-arrow-right me-2"></i>DMARC Policy
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-arrow-right me-2"></i>BIMI Support
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<i class="bi bi-arrow-right me-2"></i>Disposable Domain Check
|
||||
</li>
|
||||
|
|
@ -149,7 +158,9 @@
|
|||
|
||||
<style>
|
||||
.card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { testDomain } from "$lib/api";
|
||||
import type { DomainTestResponse } from "$lib/api/types.gen";
|
||||
import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
let domain = $derived($page.params.domain);
|
||||
let domain = $derived(page.params.domain);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let result = $state<DomainTestResponse | null>(null);
|
||||
|
|
@ -80,7 +81,8 @@
|
|||
<!-- Error State -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"
|
||||
></i>
|
||||
<h3 class="h4 mt-4">Analysis Failed</h3>
|
||||
<p class="text-muted mb-4">{error}</p>
|
||||
<button class="btn btn-primary" onclick={analyzeDomain}>
|
||||
|
|
@ -105,8 +107,9 @@
|
|||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Disposable Email Provider Detected</strong>
|
||||
<p class="mb-0 mt-1 small">
|
||||
This domain is a known temporary/disposable email service.
|
||||
Emails from this domain may have lower deliverability.
|
||||
This domain is a known temporary/disposable email
|
||||
service. Emails from this domain may have lower
|
||||
deliverability.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -116,8 +119,8 @@
|
|||
<div class="offset-md-3 col-md-3 text-center">
|
||||
<div
|
||||
class="p-2 rounded text-center summary-card"
|
||||
class:bg-light={$theme === 'light'}
|
||||
class:bg-secondary={$theme !== 'light'}
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<GradeDisplay score={result.score} grade={result.grade} />
|
||||
<small class="text-muted d-block">DNS</small>
|
||||
|
|
@ -127,7 +130,7 @@
|
|||
<div class="d-flex justify-content-end me-lg-5 mt-3">
|
||||
<TinySurvey
|
||||
class="bg-primary-subtle rounded-4 p-3 text-center"
|
||||
source={"rbl-" + result.ip}
|
||||
source={"domain-" + result.domain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -150,8 +153,8 @@
|
|||
</h3>
|
||||
<p class="mb-3">
|
||||
This domain-only test checks DNS configuration. For comprehensive
|
||||
deliverability testing including DKIM verification, content analysis,
|
||||
spam scoring, and blacklist checks:
|
||||
deliverability testing including DKIM verification, content
|
||||
analysis, spam scoring, and blacklist checks:
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">
|
||||
<i class="bi bi-envelope-plus me-2"></i>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { getTest, getReport, reanalyzeReport } from "$lib/api";
|
||||
import type { Test, Report } from "$lib/api/types.gen";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import { getReport, getTest, reanalyzeReport } from "$lib/api";
|
||||
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
|
||||
import {
|
||||
ScoreCard,
|
||||
SummaryCard,
|
||||
SpamAssassinCard,
|
||||
PendingState,
|
||||
AuthenticationCard,
|
||||
DnsRecordsCard,
|
||||
BlacklistCard,
|
||||
ContentAnalysisCard,
|
||||
HeaderAnalysisCard,
|
||||
TinySurvey,
|
||||
DnsRecordsCard,
|
||||
EmailPathCard,
|
||||
ErrorDisplay,
|
||||
HeaderAnalysisCard,
|
||||
PendingState,
|
||||
RspamdCard,
|
||||
ScoreCard,
|
||||
SpamAssassinCard,
|
||||
SummaryCard,
|
||||
TinySurvey,
|
||||
WhitelistCard,
|
||||
} from "$lib/components";
|
||||
|
||||
type BlacklistRecords = Record<string, BlacklistCheck[]>;
|
||||
|
||||
let testId = $derived(page.params.test);
|
||||
let test = $state<Test | null>(null);
|
||||
let report = $state<Report | null>(null);
|
||||
|
|
@ -188,7 +194,13 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{report ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ''} ${report.test_id?.slice(0, 7) || ''}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver</title>
|
||||
<title>
|
||||
{report
|
||||
? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ""} ${report.test_id?.slice(0, 7) || ""}`
|
||||
: test
|
||||
? `Test ${test.id.slice(0, 7)}`
|
||||
: "Loading..."} - happyDeliver
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container py-5">
|
||||
|
|
@ -283,6 +295,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Received Chain -->
|
||||
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
|
||||
<div class="row mb-4" id="received-chain">
|
||||
<div class="col-12">
|
||||
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- DNS Records -->
|
||||
{#if report.dns_results}
|
||||
<div class="row mb-4" id="dns">
|
||||
|
|
@ -313,17 +334,45 @@
|
|||
{/if}
|
||||
|
||||
<!-- Blacklist Checks -->
|
||||
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
||||
<div class="row mb-4" id="blacklist">
|
||||
<div class="col-12">
|
||||
<BlacklistCard
|
||||
blacklists={report.blacklists}
|
||||
blacklistGrade={report.summary?.blacklist_grade}
|
||||
blacklistScore={report.summary?.blacklist_score}
|
||||
receivedChain={report.header_analysis?.received_chain}
|
||||
/>
|
||||
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
|
||||
<BlacklistCard
|
||||
{blacklists}
|
||||
blacklistGrade={report.summary?.blacklist_grade}
|
||||
blacklistScore={report.summary?.blacklist_score}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- Whitelist Checks -->
|
||||
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
|
||||
<WhitelistCard {whitelists} />
|
||||
{/snippet}
|
||||
|
||||
<!-- Blacklist & Whitelist Checks -->
|
||||
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
|
||||
<div class="row mb-4">
|
||||
<div class="col-6" id="blacklist">
|
||||
{@render blacklistChecks(report.blacklists, report)}
|
||||
</div>
|
||||
<div class="col-6" id="whitelist">
|
||||
{@render whitelistChecks(report.whitelists)}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
||||
<div class="row mb-4" id="blacklist">
|
||||
<div class="col-12">
|
||||
{@render blacklistChecks(report.blacklists, report)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
|
||||
<div class="row mb-4" id="whitelist">
|
||||
<div class="col-12">
|
||||
{@render whitelistChecks(report.whitelists)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Header Analysis -->
|
||||
|
|
@ -340,16 +389,19 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Additional Information -->
|
||||
{#if report.spamassassin}
|
||||
<!-- Spam filter analysis -->
|
||||
{#if report.spamassassin || report.rspamd}
|
||||
<div class="row mb-4" id="spam">
|
||||
<div class="col-12">
|
||||
<SpamAssassinCard
|
||||
spamassassin={report.spamassassin}
|
||||
spamGrade={report.summary?.spam_grade}
|
||||
spamScore={report.summary?.spam_score}
|
||||
/>
|
||||
</div>
|
||||
{#if report.spamassassin}
|
||||
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
||||
<SpamAssassinCard spamassassin={report.spamassassin} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if report.rspamd}
|
||||
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
|
||||
<RspamdCard rspamd={report.rspamd} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue