Compare commits
2 commits
master
...
internal_r
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d1dce038f | |||
| 6ae2c2463d |
107 changed files with 3963 additions and 13830 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -26,5 +26,5 @@ logs/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# OpenAPI generated files
|
# OpenAPI generated files
|
||||||
|
internal/api/models.gen.go
|
||||||
internal/api/server.gen.go
|
internal/api/server.gen.go
|
||||||
internal/model/types.gen.go
|
|
||||||
|
|
|
||||||
23
Dockerfile
23
Dockerfile
|
|
@ -34,7 +34,7 @@ RUN go generate ./... && \
|
||||||
# Stage 3: Prepare perl and spamass-milt
|
# Stage 3: Prepare perl and spamass-milt
|
||||||
FROM alpine:3 AS pl
|
FROM alpine:3 AS pl
|
||||||
|
|
||||||
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
|
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
|
||||||
apk add --no-cache \
|
apk add --no-cache \
|
||||||
build-base \
|
build-base \
|
||||||
libmilter-dev \
|
libmilter-dev \
|
||||||
|
|
@ -55,7 +55,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
perl-json-xs \
|
perl-json-xs \
|
||||||
perl-list-moreutils \
|
perl-list-moreutils \
|
||||||
perl-moose \
|
perl-moose \
|
||||||
perl-net-idn-encode@edge \
|
perl-net-idn-encode@testing \
|
||||||
perl-net-ssleay \
|
perl-net-ssleay \
|
||||||
perl-netaddr-ip \
|
perl-netaddr-ip \
|
||||||
perl-package-stash \
|
perl-package-stash \
|
||||||
|
|
@ -86,7 +86,7 @@ RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milt
|
||||||
FROM alpine:3
|
FROM alpine:3
|
||||||
|
|
||||||
# Install all required packages
|
# Install all required packages
|
||||||
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
|
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \
|
||||||
apk add --no-cache \
|
apk add --no-cache \
|
||||||
bash \
|
bash \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|
@ -106,7 +106,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
perl-json-xs \
|
perl-json-xs \
|
||||||
perl-list-moreutils \
|
perl-list-moreutils \
|
||||||
perl-moose \
|
perl-moose \
|
||||||
perl-net-idn-encode@edge \
|
perl-net-idn-encode@testing \
|
||||||
perl-net-ssleay \
|
perl-net-ssleay \
|
||||||
perl-netaddr-ip \
|
perl-netaddr-ip \
|
||||||
perl-package-stash \
|
perl-package-stash \
|
||||||
|
|
@ -121,7 +121,6 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
perl-xml-libxml \
|
perl-xml-libxml \
|
||||||
postfix \
|
postfix \
|
||||||
postfix-pcre \
|
postfix-pcre \
|
||||||
rspamd \
|
|
||||||
spamassassin \
|
spamassassin \
|
||||||
spamassassin-client \
|
spamassassin-client \
|
||||||
supervisor \
|
supervisor \
|
||||||
|
|
@ -144,11 +143,8 @@ RUN mkdir -p /etc/happydeliver \
|
||||||
/var/lib/authentication_milter \
|
/var/lib/authentication_milter \
|
||||||
/var/spool/postfix/authentication_milter \
|
/var/spool/postfix/authentication_milter \
|
||||||
/var/spool/postfix/spamassassin \
|
/var/spool/postfix/spamassassin \
|
||||||
/var/spool/postfix/rspamd \
|
|
||||||
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
||||||
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \
|
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin
|
||||||
&& chown rspamd:mail /var/spool/postfix/rspamd \
|
|
||||||
&& chmod 750 /var/spool/postfix/rspamd
|
|
||||||
|
|
||||||
# Copy the built application
|
# Copy the built application
|
||||||
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||||
|
|
@ -158,7 +154,6 @@ RUN chmod +x /usr/local/bin/happyDeliver
|
||||||
COPY docker/postfix/ /etc/postfix/
|
COPY docker/postfix/ /etc/postfix/
|
||||||
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
|
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
|
||||||
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
||||||
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
|
|
||||||
COPY docker/supervisor/ /etc/supervisor/
|
COPY docker/supervisor/ /etc/supervisor/
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|
@ -170,13 +165,7 @@ RUN chmod +x /entrypoint.sh
|
||||||
EXPOSE 25 8080
|
EXPOSE 25 8080
|
||||||
|
|
||||||
# Default configuration
|
# Default configuration
|
||||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
|
||||||
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
|
|
||||||
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
|
||||||
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
|
||||||
HAPPYDELIVER_DNS_TIMEOUT=5s \
|
|
||||||
HAPPYDELIVER_HTTP_TIMEOUT=10s \
|
|
||||||
HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
|
|
||||||
|
|
||||||
# Volume for persistent data
|
# Volume for persistent data
|
||||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||||
|
|
|
||||||
100
README.md
100
README.md
|
|
@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
|
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
||||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||||
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
|
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
|
||||||
|
|
@ -26,7 +26,6 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha
|
||||||
- **Postfix MTA**: Receives emails on port 25
|
- **Postfix MTA**: Receives emails on port 25
|
||||||
- **authentication_milter**: Entreprise grade email authentication
|
- **authentication_milter**: Entreprise grade email authentication
|
||||||
- **SpamAssassin**: Spam scoring and analysis
|
- **SpamAssassin**: Spam scoring and analysis
|
||||||
- **rspamd**: Second spam filter for cross-validated scoring
|
|
||||||
- **happyDeliver API**: REST API server on port 8080
|
- **happyDeliver API**: REST API server on port 8080
|
||||||
- **SQLite Database**: Persistent storage for tests and reports
|
- **SQLite Database**: Persistent storage for tests and reports
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
|
||||||
cd happydeliver
|
cd happydeliver
|
||||||
|
|
||||||
# Edit docker-compose.yml to set your domain
|
# Edit docker-compose.yml to set your domain
|
||||||
# Change HAPPYDELIVER_DOMAIN environment variable and hostname
|
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
|
||||||
|
|
||||||
# Build and start
|
# Build and start
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
@ -64,54 +63,13 @@ docker run -d \
|
||||||
-p 25:25 \
|
-p 25:25 \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||||
--hostname mail.yourdomain.com \
|
-e HOSTNAME=mail.yourdomain.com \
|
||||||
-v $(pwd)/data:/var/lib/happydeliver \
|
-v $(pwd)/data:/var/lib/happydeliver \
|
||||||
-v $(pwd)/logs:/var/log/happydeliver \
|
-v $(pwd)/logs:/var/log/happydeliver \
|
||||||
happydeliver:latest
|
happydeliver:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Configure TLS Certificates (Optional but Recommended)
|
#### 3. Configure Network and DNS
|
||||||
|
|
||||||
To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments.
|
|
||||||
|
|
||||||
##### Using docker-compose
|
|
||||||
|
|
||||||
Add the certificate paths to your `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt
|
|
||||||
- POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key
|
|
||||||
volumes:
|
|
||||||
- /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro
|
|
||||||
- /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Using docker run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name happydeliver \
|
|
||||||
-p 25:25 \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
|
||||||
-e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \
|
|
||||||
-e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \
|
|
||||||
--hostname mail.yourdomain.com \
|
|
||||||
-v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \
|
|
||||||
-v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \
|
|
||||||
-v $(pwd)/data:/var/lib/happydeliver \
|
|
||||||
-v $(pwd)/logs:/var/log/happydeliver \
|
|
||||||
happydeliver:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- The certificate file should contain the full certificate chain (certificate + intermediate CAs)
|
|
||||||
- The private key file must be readable by the postfix user inside the container
|
|
||||||
- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required)
|
|
||||||
- If both environment variables are not set, Postfix will run without TLS support
|
|
||||||
|
|
||||||
#### 4. Configure Network and DNS
|
|
||||||
|
|
||||||
##### Open SMTP Port
|
##### Open SMTP Port
|
||||||
|
|
||||||
|
|
@ -163,27 +121,10 @@ The server will start on `http://localhost:8080` by default.
|
||||||
|
|
||||||
#### 3. Integrate with your existing e-mail setup
|
#### 3. Integrate with your existing e-mail setup
|
||||||
|
|
||||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
|
||||||
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||||
|
|
||||||
#### Receiver Hostname
|
Choose one of the following way to integrate happyDeliver in your existing setup:
|
||||||
|
|
||||||
happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
|
|
||||||
|
|
||||||
If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./happyDeliver server -receiver-hostname mail.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via environment variable:
|
|
||||||
```bash
|
|
||||||
HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
|
|
||||||
```
|
|
||||||
|
|
||||||
**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
|
|
||||||
|
|
||||||
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
|
||||||
|
|
||||||
#### Postfix LMTP Transport
|
#### Postfix LMTP Transport
|
||||||
|
|
||||||
|
|
@ -279,33 +220,6 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
|
||||||
|
|
||||||
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
|
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
|
||||||
|
|
||||||
## Use with happyDomain
|
|
||||||
|
|
||||||
happyDeliver can be driven by [happyDomain](https://happydomain.org) through
|
|
||||||
the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver)
|
|
||||||
plugin, so the deliverability of a domain you manage is monitored alongside
|
|
||||||
its DNS and inbound SMTP posture.
|
|
||||||
|
|
||||||
How it works:
|
|
||||||
|
|
||||||
1. Attach the **Outbound deliverability** checker to the mail service of a zone
|
|
||||||
in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`;
|
|
||||||
operators can configure a default instance globally.
|
|
||||||
2. On each run, the checker calls `POST /api/test` to allocate a fresh
|
|
||||||
recipient address, prompts the user (or an automated sender) to mail it from
|
|
||||||
the tested domain, then polls `GET /api/test/{id}` until the report is
|
|
||||||
ready.
|
|
||||||
3. The structured report from `GET /api/report/{id}` is translated into
|
|
||||||
happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam
|
|
||||||
score, blacklists and headers, plus an overall score threshold
|
|
||||||
(`min_score`/`warn_score`).
|
|
||||||
4. Runs repeat on a configurable interval so a regression in deliverability (a
|
|
||||||
new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...)
|
|
||||||
surfaces as a domain-level alert in happyDomain.
|
|
||||||
|
|
||||||
See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver)
|
|
||||||
for build instructions and the full list of run options.
|
|
||||||
|
|
||||||
## Scoring System
|
## Scoring System
|
||||||
|
|
||||||
The deliverability score is calculated from A to F based on:
|
The deliverability score is calculated from A to F based on:
|
||||||
|
|
@ -314,7 +228,7 @@ The deliverability score is calculated from A to F based on:
|
||||||
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
|
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
|
||||||
- **Blacklist**: RBL/DNSBL checks
|
- **Blacklist**: RBL/DNSBL checks
|
||||||
- **Headers**: Required headers, MIME structure, Domain alignment
|
- **Headers**: Required headers, MIME structure, Domain alignment
|
||||||
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
|
- **Spam**: SpamAssassin score
|
||||||
- **Content**: HTML quality, links, images, unsubscribe
|
- **Content**: HTML quality, links, images, unsubscribe
|
||||||
|
|
||||||
## Funding
|
## Funding
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
package: model
|
package: api
|
||||||
generate:
|
generate:
|
||||||
models: true
|
models: true
|
||||||
embedded-spec: true
|
embedded-spec: false
|
||||||
output: internal/model/types.gen.go
|
output: internal/api/models.gen.go
|
||||||
output-options:
|
|
||||||
skip-prune: true
|
|
||||||
import-mapping:
|
|
||||||
./schemas.yaml: "-"
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package: api
|
package: api
|
||||||
generate:
|
generate:
|
||||||
gin-server: true
|
gin-server: true
|
||||||
models: true
|
|
||||||
embedded-spec: true
|
embedded-spec: true
|
||||||
output: internal/api/server.gen.go
|
output: internal/api/server.gen.go
|
||||||
import-mapping:
|
|
||||||
./schemas.yaml: git.happydns.org/happyDeliver/internal/model
|
|
||||||
|
|
|
||||||
1075
api/openapi.yaml
1075
api/openapi.yaml
File diff suppressed because it is too large
Load diff
1173
api/schemas.yaml
1173
api/schemas.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -5,12 +5,12 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: happydomain/happydeliver:latest
|
image: happydomain/happydeliver:latest
|
||||||
container_name: happydeliver
|
container_name: happydeliver
|
||||||
# Set a hostname
|
|
||||||
hostname: mail.happydeliver.local
|
hostname: mail.happydeliver.local
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Set your domain
|
# Set your domain and hostname
|
||||||
HAPPYDELIVER_DOMAIN: happydeliver.local
|
DOMAIN: happydeliver.local
|
||||||
|
HOSTNAME: mail.happydeliver.local
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# SMTP port
|
# SMTP port
|
||||||
|
|
|
||||||
|
|
@ -109,37 +109,12 @@ Default configuration for the Docker environment:
|
||||||
|
|
||||||
The container accepts these environment variables:
|
The container accepts these environment variables:
|
||||||
|
|
||||||
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||||
- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
|
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
|
||||||
- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
|
|
||||||
|
|
||||||
### Receiver Hostname
|
|
||||||
|
|
||||||
happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
|
|
||||||
|
|
||||||
In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
|
|
||||||
|
|
||||||
**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
|
|
||||||
|
|
||||||
|
Example:
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
|
||||||
-e HAPPYDELIVER_DOMAIN=example.com \
|
|
||||||
-e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`.
|
|
||||||
|
|
||||||
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
|
||||||
|
|
||||||
Example (all-in-one, no override needed):
|
|
||||||
```bash
|
|
||||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Example (external MTA integration):
|
|
||||||
```bash
|
|
||||||
docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ set -e
|
||||||
echo "Starting happyDeliver container..."
|
echo "Starting happyDeliver container..."
|
||||||
|
|
||||||
# Get environment variables with defaults
|
# Get environment variables with defaults
|
||||||
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
|
HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
|
||||||
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
|
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
|
||||||
|
|
||||||
echo "Hostname: $HOSTNAME"
|
echo "Hostname: $HOSTNAME"
|
||||||
|
|
@ -15,10 +15,6 @@ mkdir -p /var/spool/postfix/authentication_milter
|
||||||
chown mail:mail /var/spool/postfix/authentication_milter
|
chown mail:mail /var/spool/postfix/authentication_milter
|
||||||
chmod 750 /var/spool/postfix/authentication_milter
|
chmod 750 /var/spool/postfix/authentication_milter
|
||||||
|
|
||||||
mkdir -p /var/spool/postfix/rspamd
|
|
||||||
chown rspamd:mail /var/spool/postfix/rspamd
|
|
||||||
chmod 750 /var/spool/postfix/rspamd
|
|
||||||
|
|
||||||
# Create log directory
|
# Create log directory
|
||||||
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
|
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
|
||||||
chown happydeliver:happydeliver /var/log/happydeliver
|
chown happydeliver:happydeliver /var/log/happydeliver
|
||||||
|
|
@ -29,15 +25,6 @@ echo "Configuring Postfix..."
|
||||||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
|
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
|
||||||
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
|
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
|
||||||
|
|
||||||
# Add certificates to postfix
|
|
||||||
[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && {
|
|
||||||
cat <<EOF >> /etc/postfix/main.cf
|
|
||||||
smtpd_tls_cert_file = ${POSTFIX_CERT_FILE}
|
|
||||||
smtpd_tls_key_file = ${POSTFIX_KEY_FILE}
|
|
||||||
smtpd_tls_security_level = may
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# Replace placeholders in configurations
|
# Replace placeholders in configurations
|
||||||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
|
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ transport_maps = pcre:/etc/postfix/transport_maps
|
||||||
# OpenDKIM for DKIM verification
|
# OpenDKIM for DKIM verification
|
||||||
milter_default_action = accept
|
milter_default_action = accept
|
||||||
milter_protocol = 6
|
milter_protocol = 6
|
||||||
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
|
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock
|
||||||
non_smtpd_milters = $smtpd_milters
|
non_smtpd_milters = $smtpd_milters
|
||||||
|
|
||||||
# SPF policy checking
|
# SPF policy checking
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
no_action = 0;
|
|
||||||
reject = null;
|
|
||||||
add_header = null;
|
|
||||||
rewrite_subject = null;
|
|
||||||
greylist = null;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Add "extended Rspamd headers"
|
|
||||||
extended_spam_headers = true;
|
|
||||||
|
|
||||||
skip_local = false;
|
|
||||||
skip_authenticated = false;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# rspamd options for happyDeliver
|
|
||||||
# Disable Bayes learning to keep the setup stateless
|
|
||||||
use_redis = false;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# Enable rspamd milter proxy worker via Unix socket for Postfix integration
|
|
||||||
bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
|
|
||||||
upstream "local" {
|
|
||||||
default = yes;
|
|
||||||
self_scan = yes;
|
|
||||||
}
|
|
||||||
|
|
@ -48,14 +48,3 @@ rbl_timeout 5
|
||||||
# Don't use user-specific rules
|
# Don't use user-specific rules
|
||||||
user_scores_dsn_timeout 3
|
user_scores_dsn_timeout 3
|
||||||
user_scores_sql_override 0
|
user_scores_sql_override 0
|
||||||
|
|
||||||
# Disable Validity network rules
|
|
||||||
dns_query_restriction deny sa-trusted.bondedsender.org
|
|
||||||
dns_query_restriction deny sa-accredit.habeas.com
|
|
||||||
dns_query_restriction deny bl.score.senderscore.com
|
|
||||||
score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
|
|
||||||
score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
|
|
||||||
score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
|
|
||||||
score RCVD_IN_VALIDITY_CERTIFIED 0
|
|
||||||
score RCVD_IN_VALIDITY_RPBL 0
|
|
||||||
score RCVD_IN_VALIDITY_SAFE 0
|
|
||||||
|
|
@ -33,16 +33,6 @@ stderr_logfile=/var/log/happydeliver/authentication_milter.log
|
||||||
user=mail
|
user=mail
|
||||||
group=mail
|
group=mail
|
||||||
|
|
||||||
# rspamd spam filter
|
|
||||||
[program:rspamd]
|
|
||||||
command=/usr/bin/rspamd -f -u rspamd -g mail
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
priority=11
|
|
||||||
stdout_logfile=/var/log/happydeliver/rspamd.log
|
|
||||||
stderr_logfile=/var/log/happydeliver/rspamd_error.log
|
|
||||||
user=root
|
|
||||||
|
|
||||||
# SpamAssassin daemon
|
# SpamAssassin daemon
|
||||||
[program:spamd]
|
[program:spamd]
|
||||||
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,5 @@
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml
|
//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml
|
||||||
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
|
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
|
||||||
|
|
|
||||||
67
go.mod
67
go.mod
|
|
@ -1,15 +1,15 @@
|
||||||
module git.happydns.org/happyDeliver
|
module git.happydns.org/happyDeliver
|
||||||
|
|
||||||
go 1.25.0
|
go 1.24.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8
|
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/getkin/kin-openapi v0.135.0
|
github.com/getkin/kin-openapi v0.133.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.3.0
|
github.com/oapi-codegen/runtime v1.1.2
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.47.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
|
@ -18,25 +18,25 @@ require (
|
||||||
require (
|
require (
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
github.com/go-openapi/jsonpointer v0.22.2 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
@ -46,35 +46,36 @@ require (
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
github.com/mailru/easyjson v0.9.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||||
|
github.com/miekg/dns v1.1.4 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.9 // indirect
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/peterzen/goresolver v1.0.2 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
github.com/quic-go/quic-go v0.56.0 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
|
github.com/redis/go-redis/v9 v9.16.0 // indirect
|
||||||
github.com/speakeasy-api/openapi v1.19.2 // indirect
|
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
144
go.sum
144
go.sum
|
|
@ -1,5 +1,5 @@
|
||||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
|
github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
|
||||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
|
github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww=
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
|
@ -10,10 +10,10 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
|
@ -22,9 +22,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||||
|
|
@ -37,18 +36,18 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
|
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||||
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
|
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
|
@ -57,15 +56,15 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
|
@ -91,8 +90,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
|
@ -119,8 +118,10 @@ github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/miekg/dns v1.1.4 h1:rCMZsU2ScVSYcAsOXgmC6+AKOK+6pmQTOcw03nfwYV0=
|
||||||
|
github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
@ -131,14 +132,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||||
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
||||||
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||||
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||||
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||||
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
|
@ -155,23 +156,24 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||||
|
github.com/peterzen/goresolver v1.0.2 h1:UxRxk835Onz7Go4oPUsOptSmBlIvN/yJ2kv3Srr3hw4=
|
||||||
|
github.com/peterzen/goresolver v1.0.2/go.mod h1:LrWRiOeCYApgvR2OhpipNOeaE1yGfI+QQjpF0riJC8M=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
|
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
|
||||||
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
|
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||||
github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M=
|
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
|
||||||
github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU=
|
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
|
||||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
|
@ -196,42 +198,38 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN
|
||||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222171317-cd391775e71e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
@ -245,21 +243,23 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -272,8 +272,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import (
|
||||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/config"
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/storage"
|
"git.happydns.org/happyDeliver/internal/storage"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"git.happydns.org/happyDeliver/internal/version"
|
"git.happydns.org/happyDeliver/internal/version"
|
||||||
|
|
@ -41,8 +40,8 @@ import (
|
||||||
// This interface breaks the circular dependency with pkg/analyzer
|
// This interface breaks the circular dependency with pkg/analyzer
|
||||||
type EmailAnalyzer interface {
|
type EmailAnalyzer interface {
|
||||||
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
||||||
AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string)
|
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
||||||
CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error)
|
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHandler implements the ServerInterface for handling API requests
|
// APIHandler implements the ServerInterface for handling API requests
|
||||||
|
|
@ -80,11 +79,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
c.JSON(http.StatusCreated, model.TestResponse{
|
c.JSON(http.StatusCreated, TestResponse{
|
||||||
Id: base32ID,
|
Id: base32ID,
|
||||||
Email: openapi_types.Email(email),
|
Email: openapi_types.Email(email),
|
||||||
Status: model.TestResponseStatusPending,
|
Status: TestResponseStatusPending,
|
||||||
Message: utils.PtrTo("Send your test email to the given address"),
|
Message: stringPtr("Send your test email to the given address"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,10 +93,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -105,20 +104,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||||
// Check if a report exists for this test ID
|
// Check if a report exists for this test ID
|
||||||
reportExists, err := h.storage.ReportExists(testUUID)
|
reportExists, err := h.storage.ReportExists(testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to check test status",
|
Message: "Failed to check test status",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status based on report existence
|
// Determine status based on report existence
|
||||||
var apiStatus model.TestStatus
|
var apiStatus TestStatus
|
||||||
if reportExists {
|
if reportExists {
|
||||||
apiStatus = model.TestStatusAnalyzed
|
apiStatus = TestStatusAnalyzed
|
||||||
} else {
|
} else {
|
||||||
apiStatus = model.TestStatusPending
|
apiStatus = TestStatusPending
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate test email address using Base32-encoded UUID
|
// Generate test email address using Base32-encoded UUID
|
||||||
|
|
@ -128,7 +127,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||||
h.config.Email.Domain,
|
h.config.Email.Domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Test{
|
c.JSON(http.StatusOK, Test{
|
||||||
Id: id,
|
Id: id,
|
||||||
Email: openapi_types.Email(email),
|
Email: openapi_types.Email(email),
|
||||||
Status: apiStatus,
|
Status: apiStatus,
|
||||||
|
|
@ -141,10 +140,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -152,16 +151,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
||||||
reportJSON, _, err := h.storage.GetReport(testUUID)
|
reportJSON, _, err := h.storage.GetReport(testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == storage.ErrNotFound {
|
if err == storage.ErrNotFound {
|
||||||
c.JSON(http.StatusNotFound, model.Error{
|
c.JSON(http.StatusNotFound, Error{
|
||||||
Error: "not_found",
|
Error: "not_found",
|
||||||
Message: "Report not found",
|
Message: "Report not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to retrieve report",
|
Message: "Failed to retrieve report",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -176,10 +175,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -187,16 +186,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
||||||
_, rawEmail, err := h.storage.GetReport(testUUID)
|
_, rawEmail, err := h.storage.GetReport(testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == storage.ErrNotFound {
|
if err == storage.ErrNotFound {
|
||||||
c.JSON(http.StatusNotFound, model.Error{
|
c.JSON(http.StatusNotFound, Error{
|
||||||
Error: "not_found",
|
Error: "not_found",
|
||||||
Message: "Email not found",
|
Message: "Email not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to retrieve raw email",
|
Message: "Failed to retrieve raw email",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -210,10 +209,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -222,16 +221,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||||
_, rawEmail, err := h.storage.GetReport(testUUID)
|
_, rawEmail, err := h.storage.GetReport(testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == storage.ErrNotFound {
|
if err == storage.ErrNotFound {
|
||||||
c.JSON(http.StatusNotFound, model.Error{
|
c.JSON(http.StatusNotFound, Error{
|
||||||
Error: "not_found",
|
Error: "not_found",
|
||||||
Message: "Email not found",
|
Message: "Email not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to retrieve email",
|
Message: "Failed to retrieve email",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -239,20 +238,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||||
// Re-analyze the email using the current analyzer
|
// Re-analyze the email using the current analyzer
|
||||||
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
|
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "analysis_error",
|
Error: "analysis_error",
|
||||||
Message: "Failed to re-analyze email",
|
Message: "Failed to re-analyze email",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the report in storage
|
// Update the report in storage
|
||||||
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
|
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to update report",
|
Message: "Failed to update report",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -268,24 +267,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
|
||||||
uptime := int(time.Since(h.startTime).Seconds())
|
uptime := int(time.Since(h.startTime).Seconds())
|
||||||
|
|
||||||
// Check database connectivity by trying to check if a report exists
|
// Check database connectivity by trying to check if a report exists
|
||||||
dbStatus := model.StatusComponentsDatabaseUp
|
dbStatus := StatusComponentsDatabaseUp
|
||||||
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
|
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
|
||||||
dbStatus = model.StatusComponentsDatabaseDown
|
dbStatus = StatusComponentsDatabaseDown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine overall status
|
// Determine overall status
|
||||||
overallStatus := model.Healthy
|
overallStatus := Healthy
|
||||||
if dbStatus == model.StatusComponentsDatabaseDown {
|
if dbStatus == StatusComponentsDatabaseDown {
|
||||||
overallStatus = model.Unhealthy
|
overallStatus = Unhealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
mtaStatus := model.StatusComponentsMtaUp
|
mtaStatus := StatusComponentsMtaUp
|
||||||
c.JSON(http.StatusOK, model.Status{
|
c.JSON(http.StatusOK, Status{
|
||||||
Status: overallStatus,
|
Status: overallStatus,
|
||||||
Version: version.Version,
|
Version: version.Version,
|
||||||
Components: &struct {
|
Components: &struct {
|
||||||
Database *model.StatusComponentsDatabase `json:"database,omitempty"`
|
Database *StatusComponentsDatabase `json:"database,omitempty"`
|
||||||
Mta *model.StatusComponentsMta `json:"mta,omitempty"`
|
Mta *StatusComponentsMta `json:"mta,omitempty"`
|
||||||
}{
|
}{
|
||||||
Database: &dbStatus,
|
Database: &dbStatus,
|
||||||
Mta: &mtaStatus,
|
Mta: &mtaStatus,
|
||||||
|
|
@ -297,14 +296,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
|
||||||
// TestDomain performs synchronous domain analysis
|
// TestDomain performs synchronous domain analysis
|
||||||
// (POST /domain)
|
// (POST /domain)
|
||||||
func (h *APIHandler) TestDomain(c *gin.Context) {
|
func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||||
var request model.DomainTestRequest
|
var request DomainTestRequest
|
||||||
|
|
||||||
// Bind and validate request
|
// Bind and validate request
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_request",
|
Error: "invalid_request",
|
||||||
Message: "Invalid request body",
|
Message: "Invalid request body",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -313,28 +312,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||||
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
|
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
|
||||||
|
|
||||||
// Convert grade string to DomainTestResponseGrade enum
|
// Convert grade string to DomainTestResponseGrade enum
|
||||||
var responseGrade model.DomainTestResponseGrade
|
var responseGrade DomainTestResponseGrade
|
||||||
switch grade {
|
switch grade {
|
||||||
case "A+":
|
case "A+":
|
||||||
responseGrade = model.DomainTestResponseGradeA
|
responseGrade = DomainTestResponseGradeA
|
||||||
case "A":
|
case "A":
|
||||||
responseGrade = model.DomainTestResponseGradeA1
|
responseGrade = DomainTestResponseGradeA1
|
||||||
case "B":
|
case "B":
|
||||||
responseGrade = model.DomainTestResponseGradeB
|
responseGrade = DomainTestResponseGradeB
|
||||||
case "C":
|
case "C":
|
||||||
responseGrade = model.DomainTestResponseGradeC
|
responseGrade = DomainTestResponseGradeC
|
||||||
case "D":
|
case "D":
|
||||||
responseGrade = model.DomainTestResponseGradeD
|
responseGrade = DomainTestResponseGradeD
|
||||||
case "E":
|
case "E":
|
||||||
responseGrade = model.DomainTestResponseGradeE
|
responseGrade = DomainTestResponseGradeE
|
||||||
case "F":
|
case "F":
|
||||||
responseGrade = model.DomainTestResponseGradeF
|
responseGrade = DomainTestResponseGradeF
|
||||||
default:
|
default:
|
||||||
responseGrade = model.DomainTestResponseGradeF
|
responseGrade = DomainTestResponseGradeF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
response := model.DomainTestResponse{
|
response := DomainTestResponse{
|
||||||
Domain: request.Domain,
|
Domain: request.Domain,
|
||||||
Score: score,
|
Score: score,
|
||||||
Grade: responseGrade,
|
Grade: responseGrade,
|
||||||
|
|
@ -347,79 +346,37 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||||
// CheckBlacklist checks an IP address against DNS blacklists
|
// CheckBlacklist checks an IP address against DNS blacklists
|
||||||
// (POST /blacklist)
|
// (POST /blacklist)
|
||||||
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
var request model.BlacklistCheckRequest
|
var request BlacklistCheckRequest
|
||||||
|
|
||||||
// Bind and validate request
|
// Bind and validate request
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_request",
|
Error: "invalid_request",
|
||||||
Message: "Invalid request body",
|
Message: "Invalid request body",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform blacklist check using analyzer
|
// Perform blacklist check using analyzer
|
||||||
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_ip",
|
Error: "invalid_ip",
|
||||||
Message: "Invalid IP address",
|
Message: "Invalid IP address",
|
||||||
Details: utils.PtrTo(err.Error()),
|
Details: stringPtr(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
response := model.BlacklistCheckResponse{
|
response := BlacklistCheckResponse{
|
||||||
Ip: request.Ip,
|
Ip: request.Ip,
|
||||||
Blacklists: checks,
|
Checks: checks,
|
||||||
Whitelists: &whitelists,
|
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
Score: score,
|
Score: score,
|
||||||
Grade: model.BlacklistCheckResponseGrade(grade),
|
Grade: BlacklistCheckResponseGrade(grade),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTests returns a paginated list of test summaries
|
|
||||||
// (GET /tests)
|
|
||||||
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
|
|
||||||
if h.config.DisableTestList {
|
|
||||||
c.JSON(http.StatusForbidden, model.Error{
|
|
||||||
Error: "feature_disabled",
|
|
||||||
Message: "Test listing is disabled on this instance",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := 0
|
|
||||||
limit := 20
|
|
||||||
if params.Offset != nil {
|
|
||||||
offset = *params.Offset
|
|
||||||
}
|
|
||||||
if params.Limit != nil {
|
|
||||||
limit = *params.Limit
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests, total, err := h.storage.ListReportSummaries(offset, limit)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, model.Error{
|
|
||||||
Error: "internal_error",
|
|
||||||
Message: "Failed to list tests",
|
|
||||||
Details: utils.PtrTo(err.Error()),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.TestListResponse{
|
|
||||||
Tests: tests,
|
|
||||||
Total: int(total),
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
// This file is part of the happyDeliver (R) project.
|
||||||
// Copyright (c) 2026 happyDomain
|
// Copyright (c) 2025 happyDomain
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
//
|
//
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
|
@ -19,7 +19,11 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
package utils
|
package api
|
||||||
|
|
||||||
|
func stringPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
// PtrTo returns a pointer to the provided value
|
// PtrTo returns a pointer to the provided value
|
||||||
func PtrTo[T any](v T) *T {
|
func PtrTo[T any](v T) *T {
|
||||||
|
|
@ -34,17 +34,13 @@ func declareFlags(o *Config) {
|
||||||
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
||||||
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
||||||
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
||||||
flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())")
|
|
||||||
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
||||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
||||||
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
||||||
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
|
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
|
||||||
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
|
|
||||||
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
|
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
|
||||||
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
||||||
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||||
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
|
|
||||||
flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint")
|
|
||||||
|
|
||||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,6 @@ import (
|
||||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getHostname() string {
|
|
||||||
h, _ := os.Hostname()
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DevProxy string
|
DevProxy string
|
||||||
|
|
@ -49,8 +44,6 @@ type Config struct {
|
||||||
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
||||||
RateLimit uint // API rate limit (requests per second per IP)
|
RateLimit uint // API rate limit (requests per second per IP)
|
||||||
SurveyURL url.URL // URL for user feedback survey
|
SurveyURL url.URL // URL for user feedback survey
|
||||||
CustomLogoURL string // URL for custom logo image in the web UI
|
|
||||||
DisableTestList bool // Disable the public test listing endpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig contains database connection settings
|
// DatabaseConfig contains database connection settings
|
||||||
|
|
@ -64,7 +57,6 @@ type EmailConfig struct {
|
||||||
Domain string
|
Domain string
|
||||||
TestAddressPrefix string
|
TestAddressPrefix string
|
||||||
LMTPAddr string
|
LMTPAddr string
|
||||||
ReceiverHostname string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||||
|
|
@ -72,9 +64,7 @@ type AnalysisConfig struct {
|
||||||
DNSTimeout time.Duration
|
DNSTimeout time.Duration
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
RBLs []string
|
RBLs []string
|
||||||
DNSWLs []string
|
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
|
||||||
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns a configuration with sensible defaults
|
// DefaultConfig returns a configuration with sensible defaults
|
||||||
|
|
@ -92,13 +82,11 @@ func DefaultConfig() *Config {
|
||||||
Domain: "happydeliver.local",
|
Domain: "happydeliver.local",
|
||||||
TestAddressPrefix: "test-",
|
TestAddressPrefix: "test-",
|
||||||
LMTPAddr: "127.0.0.1:2525",
|
LMTPAddr: "127.0.0.1:2525",
|
||||||
ReceiverHostname: getHostname(),
|
|
||||||
},
|
},
|
||||||
Analysis: AnalysisConfig{
|
Analysis: AnalysisConfig{
|
||||||
DNSTimeout: 5 * time.Second,
|
DNSTimeout: 5 * time.Second,
|
||||||
HTTPTimeout: 10 * time.Second,
|
HTTPTimeout: 10 * time.Second,
|
||||||
RBLs: []string{},
|
RBLs: []string{},
|
||||||
DNSWLs: []string{},
|
|
||||||
CheckAllIPs: false, // By default, only check the first IP
|
CheckAllIPs: false, // By default, only check the first IP
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,17 +98,6 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
||||||
|
|
||||||
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
||||||
|
|
||||||
// Warn if the last Received hop doesn't match the expected receiver hostname
|
|
||||||
if r.config.Email.ReceiverHostname != "" &&
|
|
||||||
result.Report.HeaderAnalysis != nil &&
|
|
||||||
result.Report.HeaderAnalysis.ReceivedChain != nil &&
|
|
||||||
len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 {
|
|
||||||
lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0]
|
|
||||||
if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname {
|
|
||||||
log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal report to JSON
|
// Marshal report to JSON
|
||||||
reportJSON, err := json.Marshal(result.Report)
|
reportJSON, err := json.Marshal(result.Report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,6 @@ import (
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -48,7 +45,6 @@ type Storage interface {
|
||||||
ReportExists(testID uuid.UUID) (bool, error)
|
ReportExists(testID uuid.UUID) (bool, error)
|
||||||
UpdateReport(testID uuid.UUID, reportJSON []byte) error
|
UpdateReport(testID uuid.UUID, reportJSON []byte) error
|
||||||
DeleteOldReports(olderThan time.Time) (int64, error)
|
DeleteOldReports(olderThan time.Time) (int64, error)
|
||||||
ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
|
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
Close() error
|
Close() error
|
||||||
|
|
@ -143,72 +139,6 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
|
||||||
return result.RowsAffected, nil
|
return result.RowsAffected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
|
|
||||||
type reportSummaryRow struct {
|
|
||||||
TestID uuid.UUID
|
|
||||||
Score int
|
|
||||||
Grade string
|
|
||||||
FromDomain string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListReportSummaries returns a paginated list of lightweight report summaries
|
|
||||||
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
|
|
||||||
var total int64
|
|
||||||
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if total == 0 {
|
|
||||||
return []model.TestSummary{}, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectExpr string
|
|
||||||
switch s.db.Dialector.Name() {
|
|
||||||
case "postgres":
|
|
||||||
selectExpr = `test_id, ` +
|
|
||||||
`(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
|
|
||||||
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
|
|
||||||
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
|
|
||||||
`created_at`
|
|
||||||
case "sqlite":
|
|
||||||
selectExpr = `test_id, ` +
|
|
||||||
`json_extract(report_json, '$.score') as score, ` +
|
|
||||||
`json_extract(report_json, '$.grade') as grade, ` +
|
|
||||||
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
|
|
||||||
`created_at`
|
|
||||||
default:
|
|
||||||
return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []reportSummaryRow
|
|
||||||
err := s.db.Model(&Report{}).
|
|
||||||
Select(selectExpr).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(limit).
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries := make([]model.TestSummary, 0, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
s := model.TestSummary{
|
|
||||||
TestId: utils.UUIDToBase32(r.TestID),
|
|
||||||
Score: r.Score,
|
|
||||||
Grade: model.TestSummaryGrade(r.Grade),
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
}
|
|
||||||
if r.FromDomain != "" {
|
|
||||||
s.FromDomain = utils.PtrTo(r.FromDomain)
|
|
||||||
}
|
|
||||||
summaries = append(summaries, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summaries, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
func (s *DBStorage) Close() error {
|
func (s *DBStorage) Close() error {
|
||||||
sqlDB, err := s.db.DB()
|
sqlDB, err := s.db.DB()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/config"
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,13 +41,10 @@ type EmailAnalyzer struct {
|
||||||
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
||||||
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||||
generator := NewReportGenerator(
|
generator := NewReportGenerator(
|
||||||
cfg.Email.ReceiverHostname,
|
|
||||||
cfg.Analysis.DNSTimeout,
|
cfg.Analysis.DNSTimeout,
|
||||||
cfg.Analysis.HTTPTimeout,
|
cfg.Analysis.HTTPTimeout,
|
||||||
cfg.Analysis.RBLs,
|
cfg.Analysis.RBLs,
|
||||||
cfg.Analysis.DNSWLs,
|
|
||||||
cfg.Analysis.CheckAllIPs,
|
cfg.Analysis.CheckAllIPs,
|
||||||
cfg.Analysis.RspamdAPIURL,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return &EmailAnalyzer{
|
return &EmailAnalyzer{
|
||||||
|
|
@ -59,7 +56,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||||
type AnalysisResult struct {
|
type AnalysisResult struct {
|
||||||
Email *EmailMessage
|
Email *EmailMessage
|
||||||
Results *AnalysisResults
|
Results *AnalysisResults
|
||||||
Report *model.Report
|
Report *api.Report
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeEmailBytes performs complete email analysis from raw bytes
|
// AnalyzeEmailBytes performs complete email analysis from raw bytes
|
||||||
|
|
@ -113,7 +110,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeDomain performs DNS analysis for a domain and returns the results
|
// AnalyzeDomain performs DNS analysis for a domain and returns the results
|
||||||
func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
|
func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
|
||||||
// Perform DNS analysis
|
// Perform DNS analysis
|
||||||
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
|
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
|
||||||
|
|
||||||
|
|
@ -123,28 +120,22 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, strin
|
||||||
return dnsResults, score, grade
|
return dnsResults, score, grade
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
// CheckBlacklistIP checks a single IP address against DNS blacklists
|
||||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) {
|
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
|
||||||
// Check the IP against all configured RBLs
|
// Check the IP against all configured RBLs
|
||||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, 0, 0, "", err
|
return nil, 0, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate score using the existing function
|
// Calculate score using the existing function
|
||||||
// Create a minimal RBLResults structure for scoring
|
// Create a minimal RBLResults structure for scoring
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]model.BlacklistCheck{ip: checks},
|
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
||||||
IPsChecked: []string{ip},
|
IPsChecked: []string{ip},
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
}
|
}
|
||||||
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
|
score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results)
|
||||||
|
|
||||||
// Check the IP against all configured DNSWLs (informational only)
|
return checks, listedCount, score, grade, nil
|
||||||
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
|
||||||
if err != nil {
|
|
||||||
whitelists = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return checks, whitelists, listedCount, score, grade, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,25 +24,23 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthenticationAnalyzer analyzes email authentication results
|
// AuthenticationAnalyzer analyzes email authentication results
|
||||||
type AuthenticationAnalyzer struct {
|
type AuthenticationAnalyzer struct{}
|
||||||
receiverHostname string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||||
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
|
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
||||||
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
|
return &AuthenticationAnalyzer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||||
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
|
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
|
|
||||||
// Parse Authentication-Results headers
|
// Parse Authentication-Results headers
|
||||||
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
authHeaders := email.GetAuthenticationResults()
|
||||||
for _, header := range authHeaders {
|
for _, header := range authHeaders {
|
||||||
a.parseAuthenticationResultsHeader(header, results)
|
a.parseAuthenticationResultsHeader(header, results)
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +63,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
|
||||||
|
|
||||||
// parseAuthenticationResultsHeader parses an Authentication-Results header
|
// parseAuthenticationResultsHeader parses an Authentication-Results header
|
||||||
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
|
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
|
||||||
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
|
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
|
||||||
// Split by semicolon to get individual results
|
// Split by semicolon to get individual results
|
||||||
parts := strings.Split(header, ";")
|
parts := strings.Split(header, ";")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
|
|
@ -91,7 +89,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
dkimResult := a.parseDKIMResult(part)
|
dkimResult := a.parseDKIMResult(part)
|
||||||
if dkimResult != nil {
|
if dkimResult != nil {
|
||||||
if results.Dkim == nil {
|
if results.Dkim == nil {
|
||||||
dkimList := []model.AuthResult{*dkimResult}
|
dkimList := []api.AuthResult{*dkimResult}
|
||||||
results.Dkim = &dkimList
|
results.Dkim = &dkimList
|
||||||
} else {
|
} else {
|
||||||
*results.Dkim = append(*results.Dkim, *dkimResult)
|
*results.Dkim = append(*results.Dkim, *dkimResult)
|
||||||
|
|
@ -145,39 +143,34 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
|
|
||||||
// CalculateAuthenticationScore calculates the authentication score from auth results
|
// CalculateAuthenticationScore calculates the authentication score from auth results
|
||||||
// Returns a score from 0-100 where higher is better
|
// Returns a score from 0-100 where higher is better
|
||||||
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
|
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
score := 0
|
score := 0
|
||||||
|
|
||||||
// Core authentication (90 points total)
|
// IPRev (15 points)
|
||||||
// SPF (30 points)
|
score += 15 * a.calculateIPRevScore(results) / 100
|
||||||
score += 30 * a.calculateSPFScore(results) / 100
|
|
||||||
|
|
||||||
// DKIM (30 points)
|
// SPF (25 points)
|
||||||
score += 30 * a.calculateDKIMScore(results) / 100
|
score += 25 * a.calculateSPFScore(results) / 100
|
||||||
|
|
||||||
// DMARC (30 points)
|
// DKIM (23 points)
|
||||||
score += 30 * a.calculateDMARCScore(results) / 100
|
score += 23 * a.calculateDKIMScore(results) / 100
|
||||||
|
|
||||||
|
// X-Google-DKIM (optional) - penalty if failed
|
||||||
|
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
||||||
|
|
||||||
|
// X-Aligned-From
|
||||||
|
score += 2 * a.calculateXAlignedFromScore(results) / 100
|
||||||
|
|
||||||
|
// DMARC (25 points)
|
||||||
|
score += 25 * a.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
// BIMI (10 points)
|
// BIMI (10 points)
|
||||||
score += 10 * a.calculateBIMIScore(results) / 100
|
score += 10 * a.calculateBIMIScore(results) / 100
|
||||||
|
|
||||||
// Penalty-only: IPRev (up to -7 points on failure)
|
|
||||||
if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
|
|
||||||
score += 7 * (iprevScore - 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Penalty-only: X-Google-DKIM (up to -12 points on failure)
|
|
||||||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
|
||||||
|
|
||||||
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
|
||||||
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 {
|
|
||||||
score += 5 * (xAlignedScore - 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure score doesn't exceed 100
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// textprotoCanonical converts a header name to canonical form
|
// textprotoCanonical converts a header name to canonical form
|
||||||
|
|
@ -53,24 +52,24 @@ func pluralize(count int) string {
|
||||||
|
|
||||||
// parseARCResult parses ARC result from Authentication-Results
|
// parseARCResult parses ARC result from Authentication-Results
|
||||||
// Example: arc=pass
|
// Example: arc=pass
|
||||||
func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult {
|
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
||||||
result := &model.ARCResult{}
|
result := &api.ARCResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, none)
|
// Extract result (pass, fail, none)
|
||||||
re := regexp.MustCompile(`arc=(\w+)`)
|
re := regexp.MustCompile(`arc=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.ARCResultResult(resultStr)
|
result.Result = api.ARCResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseARCHeaders parses ARC headers from email message
|
// parseARCHeaders parses ARC headers from email message
|
||||||
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
||||||
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult {
|
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
||||||
// Get all ARC-related headers
|
// Get all ARC-related headers
|
||||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||||
|
|
@ -81,8 +80,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &model.ARCResult{
|
result := &api.ARCResult{
|
||||||
Result: model.ARCResultResultNone,
|
Result: api.ARCResultResultNone,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count the ARC chain length (number of sets)
|
// Count the ARC chain length (number of sets)
|
||||||
|
|
@ -95,15 +94,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC
|
||||||
|
|
||||||
// Determine overall result
|
// Determine overall result
|
||||||
if chainLength == 0 {
|
if chainLength == 0 {
|
||||||
result.Result = model.ARCResultResultNone
|
result.Result = api.ARCResultResultNone
|
||||||
details := "No ARC chain present"
|
details := "No ARC chain present"
|
||||||
result.Details = &details
|
result.Details = &details
|
||||||
} else if !chainValid {
|
} else if !chainValid {
|
||||||
result.Result = model.ARCResultResultFail
|
result.Result = api.ARCResultResultFail
|
||||||
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
||||||
result.Details = &details
|
result.Details = &details
|
||||||
} else {
|
} else {
|
||||||
result.Result = model.ARCResultResultPass
|
result.Result = api.ARCResultResultPass
|
||||||
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
||||||
result.Details = &details
|
result.Details = &details
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +111,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC
|
||||||
}
|
}
|
||||||
|
|
||||||
// enhanceARCResult enhances an existing ARC result with chain information
|
// enhanceARCResult enhances an existing ARC result with chain information
|
||||||
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) {
|
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
||||||
if arcResult == nil {
|
if arcResult == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,33 +24,33 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseARCResult(t *testing.T) {
|
func TestParseARCResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.ARCResultResult
|
expectedResult api.ARCResultResult
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ARC pass",
|
name: "ARC pass",
|
||||||
part: "arc=pass",
|
part: "arc=pass",
|
||||||
expectedResult: model.ARCResultResultPass,
|
expectedResult: api.ARCResultResultPass,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC fail",
|
name: "ARC fail",
|
||||||
part: "arc=fail",
|
part: "arc=fail",
|
||||||
expectedResult: model.ARCResultResultFail,
|
expectedResult: api.ARCResultResultFail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC none",
|
name: "ARC none",
|
||||||
part: "arc=none",
|
part: "arc=none",
|
||||||
expectedResult: model.ARCResultResultNone,
|
expectedResult: api.ARCResultResultNone,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseBIMIResult parses BIMI result from Authentication-Results
|
// parseBIMIResult parses BIMI result from Authentication-Results
|
||||||
// Example: bimi=pass header.d=example.com header.selector=default
|
// Example: bimi=pass header.d=example.com header.selector=default
|
||||||
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
||||||
result := &model.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`bimi=(\w+)`)
|
re := regexp.MustCompile(`bimi=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
// Extract domain (header.d or d)
|
||||||
|
|
@ -55,17 +54,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult
|
||||||
result.Selector = &selector
|
result.Selector = &selector
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
|
||||||
if results.Bimi != nil {
|
if results.Bimi != nil {
|
||||||
switch results.Bimi.Result {
|
switch results.Bimi.Result {
|
||||||
case model.AuthResultResultPass:
|
case api.AuthResultResultPass:
|
||||||
return 100
|
return 100
|
||||||
case model.AuthResultResultDeclined:
|
case api.AuthResultResultDeclined:
|
||||||
return 59
|
return 59
|
||||||
default: // fail
|
default: // fail
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,47 +24,47 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseBIMIResult(t *testing.T) {
|
func TestParseBIMIResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
expectedSelector string
|
expectedSelector string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "BIMI pass with domain and selector",
|
name: "BIMI pass with domain and selector",
|
||||||
part: "bimi=pass header.d=example.com header.selector=default",
|
part: "bimi=pass header.d=example.com header.selector=default",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI fail",
|
name: "BIMI fail",
|
||||||
part: "bimi=fail header.d=example.com header.selector=default",
|
part: "bimi=fail header.d=example.com header.selector=default",
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI with short form (d= and selector=)",
|
name: "BIMI with short form (d= and selector=)",
|
||||||
part: "bimi=pass d=example.com selector=v1",
|
part: "bimi=pass d=example.com selector=v1",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "v1",
|
expectedSelector: "v1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI none",
|
name: "BIMI none",
|
||||||
part: "bimi=none header.d=example.com",
|
part: "bimi=none header.d=example.com",
|
||||||
expectedResult: model.AuthResultResultNone,
|
expectedResult: api.AuthResultResultNone,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseDKIMResult parses DKIM result from Authentication-Results
|
// parseDKIMResult parses DKIM result from Authentication-Results
|
||||||
// Example: dkim=pass header.d=example.com header.s=selector1
|
// Example: dkim=pass header.d=example.com header.s=selector1
|
||||||
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
|
||||||
result := &model.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`dkim=(\w+)`)
|
re := regexp.MustCompile(`dkim=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
// Extract domain (header.d or d)
|
||||||
|
|
@ -55,18 +54,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult
|
||||||
result.Selector = &selector
|
result.Selector = &selector
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
|
||||||
// Expect at least one passing signature
|
// Expect at least one passing signature
|
||||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
hasPass := false
|
hasPass := false
|
||||||
hasNonPass := false
|
hasNonPass := false
|
||||||
for _, dkim := range *results.Dkim {
|
for _, dkim := range *results.Dkim {
|
||||||
if dkim.Result == model.AuthResultResultPass {
|
if dkim.Result == api.AuthResultResultPass {
|
||||||
hasPass = true
|
hasPass = true
|
||||||
} else {
|
} else {
|
||||||
hasNonPass = true
|
hasNonPass = true
|
||||||
|
|
|
||||||
|
|
@ -24,41 +24,41 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseDKIMResult(t *testing.T) {
|
func TestParseDKIMResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
expectedSelector string
|
expectedSelector string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "DKIM pass with domain and selector",
|
name: "DKIM pass with domain and selector",
|
||||||
part: "dkim=pass header.d=example.com header.s=default",
|
part: "dkim=pass header.d=example.com header.s=default",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DKIM fail",
|
name: "DKIM fail",
|
||||||
part: "dkim=fail header.d=example.com header.s=selector1",
|
part: "dkim=fail header.d=example.com header.s=selector1",
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "selector1",
|
expectedSelector: "selector1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DKIM with short form (d= and s=)",
|
name: "DKIM with short form (d= and s=)",
|
||||||
part: "dkim=pass d=example.com s=default",
|
part: "dkim=pass d=example.com s=default",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseDMARCResult parses DMARC result from Authentication-Results
|
// parseDMARCResult parses DMARC result from Authentication-Results
|
||||||
// Example: dmarc=pass action=none header.from=example.com
|
// Example: dmarc=pass action=none header.from=example.com
|
||||||
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
||||||
result := &model.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`dmarc=(\w+)`)
|
re := regexp.MustCompile(`dmarc=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.from)
|
// Extract domain (header.from)
|
||||||
|
|
@ -48,17 +47,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult
|
||||||
result.Domain = &domain
|
result.Domain = &domain
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
|
||||||
if results.Dmarc != nil {
|
if results.Dmarc != nil {
|
||||||
switch results.Dmarc.Result {
|
switch results.Dmarc.Result {
|
||||||
case model.AuthResultResultPass:
|
case api.AuthResultResultPass:
|
||||||
return 100
|
return 100
|
||||||
case model.AuthResultResultNone:
|
case api.AuthResultResultNone:
|
||||||
return 33
|
return 33
|
||||||
default: // fail
|
default: // fail
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,31 +24,31 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseDMARCResult(t *testing.T) {
|
func TestParseDMARCResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "DMARC pass",
|
name: "DMARC pass",
|
||||||
part: "dmarc=pass action=none header.from=example.com",
|
part: "dmarc=pass action=none header.from=example.com",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DMARC fail",
|
name: "DMARC fail",
|
||||||
part: "dmarc=fail action=quarantine header.from=example.com",
|
part: "dmarc=fail action=quarantine header.from=example.com",
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
|
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
|
||||||
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
|
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
|
||||||
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult {
|
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
|
||||||
result := &model.IPRevResult{}
|
result := &api.IPRevResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, temperror, permerror, none)
|
// Extract result (pass, fail, temperror, permerror, none)
|
||||||
re := regexp.MustCompile(`iprev=(\w+)`)
|
re := regexp.MustCompile(`iprev=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.IPRevResultResult(resultStr)
|
result.Result = api.IPRevResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract IP address (smtp.remote-ip or remote-ip)
|
// Extract IP address (smtp.remote-ip or remote-ip)
|
||||||
|
|
@ -55,20 +54,20 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResul
|
||||||
result.Hostname = &hostname
|
result.Hostname = &hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
|
||||||
if results.Iprev != nil {
|
if results.Iprev != nil {
|
||||||
switch results.Iprev.Result {
|
switch results.Iprev.Result {
|
||||||
case model.Pass:
|
case api.Pass:
|
||||||
return 100
|
return 100
|
||||||
default: // fail, temperror, permerror
|
default: // fail, temperror, permerror
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 100
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,77 +24,76 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseIPRevResult(t *testing.T) {
|
func TestParseIPRevResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.IPRevResultResult
|
expectedResult api.IPRevResultResult
|
||||||
expectedIP *string
|
expectedIP *string
|
||||||
expectedHostname *string
|
expectedHostname *string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "IPRev pass with IP and hostname",
|
name: "IPRev pass with IP and hostname",
|
||||||
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||||
expectedResult: model.Pass,
|
expectedResult: api.Pass,
|
||||||
expectedIP: utils.PtrTo("195.110.101.58"),
|
expectedIP: api.PtrTo("195.110.101.58"),
|
||||||
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
|
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev pass without smtp prefix",
|
name: "IPRev pass without smtp prefix",
|
||||||
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
|
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
|
||||||
expectedResult: model.Pass,
|
expectedResult: api.Pass,
|
||||||
expectedIP: utils.PtrTo("192.0.2.1"),
|
expectedIP: api.PtrTo("192.0.2.1"),
|
||||||
expectedHostname: utils.PtrTo("mail.example.com"),
|
expectedHostname: api.PtrTo("mail.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev fail",
|
name: "IPRev fail",
|
||||||
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
|
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
|
||||||
expectedResult: model.Fail,
|
expectedResult: api.Fail,
|
||||||
expectedIP: utils.PtrTo("198.51.100.42"),
|
expectedIP: api.PtrTo("198.51.100.42"),
|
||||||
expectedHostname: utils.PtrTo("unknown.host.com"),
|
expectedHostname: api.PtrTo("unknown.host.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev temperror",
|
name: "IPRev temperror",
|
||||||
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
|
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
|
||||||
expectedResult: model.Temperror,
|
expectedResult: api.Temperror,
|
||||||
expectedIP: utils.PtrTo("203.0.113.1"),
|
expectedIP: api.PtrTo("203.0.113.1"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev permerror",
|
name: "IPRev permerror",
|
||||||
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
|
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
|
||||||
expectedResult: model.Permerror,
|
expectedResult: api.Permerror,
|
||||||
expectedIP: utils.PtrTo("192.0.2.100"),
|
expectedIP: api.PtrTo("192.0.2.100"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev with IPv6",
|
name: "IPRev with IPv6",
|
||||||
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
|
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
|
||||||
expectedResult: model.Pass,
|
expectedResult: api.Pass,
|
||||||
expectedIP: utils.PtrTo("2001:db8::1"),
|
expectedIP: api.PtrTo("2001:db8::1"),
|
||||||
expectedHostname: utils.PtrTo("ipv6.example.com"),
|
expectedHostname: api.PtrTo("ipv6.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev with subdomain hostname",
|
name: "IPRev with subdomain hostname",
|
||||||
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
|
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
|
||||||
expectedResult: model.Pass,
|
expectedResult: api.Pass,
|
||||||
expectedIP: utils.PtrTo("192.0.2.50"),
|
expectedIP: api.PtrTo("192.0.2.50"),
|
||||||
expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
|
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev pass without parentheses",
|
name: "IPRev pass without parentheses",
|
||||||
part: "iprev=pass smtp.remote-ip=192.0.2.200",
|
part: "iprev=pass smtp.remote-ip=192.0.2.200",
|
||||||
expectedResult: model.Pass,
|
expectedResult: api.Pass,
|
||||||
expectedIP: utils.PtrTo("192.0.2.200"),
|
expectedIP: api.PtrTo("192.0.2.200"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -143,29 +142,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
expectedIPRevResult *model.IPRevResultResult
|
expectedIPRevResult *api.IPRevResultResult
|
||||||
expectedIP *string
|
expectedIP *string
|
||||||
expectedHostname *string
|
expectedHostname *string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "IPRev pass in Authentication-Results",
|
name: "IPRev pass in Authentication-Results",
|
||||||
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||||
expectedIPRevResult: utils.PtrTo(model.Pass),
|
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||||
expectedIP: utils.PtrTo("195.110.101.58"),
|
expectedIP: api.PtrTo("195.110.101.58"),
|
||||||
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
|
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev with other authentication methods",
|
name: "IPRev with other authentication methods",
|
||||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
|
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
|
||||||
expectedIPRevResult: utils.PtrTo(model.Pass),
|
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||||
expectedIP: utils.PtrTo("192.0.2.1"),
|
expectedIP: api.PtrTo("192.0.2.1"),
|
||||||
expectedHostname: utils.PtrTo("mail.example.com"),
|
expectedHostname: api.PtrTo("mail.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev fail",
|
name: "IPRev fail",
|
||||||
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
|
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
|
||||||
expectedIPRevResult: utils.PtrTo(model.Fail),
|
expectedIPRevResult: api.PtrTo(api.Fail),
|
||||||
expectedIP: utils.PtrTo("198.51.100.42"),
|
expectedIP: api.PtrTo("198.51.100.42"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -176,17 +175,17 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Multiple IPRev results - only first is parsed",
|
name: "Multiple IPRev results - only first is parsed",
|
||||||
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
|
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
|
||||||
expectedIPRevResult: utils.PtrTo(model.Pass),
|
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||||
expectedIP: utils.PtrTo("192.0.2.1"),
|
expectedIP: api.PtrTo("192.0.2.1"),
|
||||||
expectedHostname: utils.PtrTo("first.com"),
|
expectedHostname: api.PtrTo("first.com"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||||
|
|
||||||
// Check IPRev
|
// Check IPRev
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseSPFResult parses SPF result from Authentication-Results
|
// parseSPFResult parses SPF result from Authentication-Results
|
||||||
// Example: spf=pass smtp.mailfrom=sender@example.com
|
// Example: spf=pass smtp.mailfrom=sender@example.com
|
||||||
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
|
||||||
result := &model.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`spf=(\w+)`)
|
re := regexp.MustCompile(`spf=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain
|
// Extract domain
|
||||||
|
|
@ -52,35 +51,25 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
||||||
receivedSPF := email.Header.Get("Received-SPF")
|
receivedSPF := email.Header.Get("Received-SPF")
|
||||||
if receivedSPF == "" {
|
if receivedSPF == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify receiver matches our hostname
|
result := &api.AuthResult{}
|
||||||
if a.receiverHostname != "" {
|
|
||||||
receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`)
|
|
||||||
if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
|
|
||||||
if matches[1] != a.receiverHostname {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &model.AuthResult{}
|
|
||||||
|
|
||||||
// Extract result (first word)
|
// Extract result (first word)
|
||||||
parts := strings.Fields(receivedSPF)
|
parts := strings.Fields(receivedSPF)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
resultStr := strings.ToLower(parts[0])
|
resultStr := strings.ToLower(parts[0])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = &receivedSPF
|
result.Details = &receivedSPF
|
||||||
|
|
@ -98,14 +87,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.Auth
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
|
||||||
if results.Spf != nil {
|
if results.Spf != nil {
|
||||||
switch results.Spf.Result {
|
switch results.Spf.Result {
|
||||||
case model.AuthResultResultPass:
|
case api.AuthResultResultPass:
|
||||||
return 100
|
return 100
|
||||||
case model.AuthResultResultNeutral, model.AuthResultResultNone:
|
case api.AuthResultResultNeutral, api.AuthResultResultNone:
|
||||||
return 50
|
return 50
|
||||||
case model.AuthResultResultSoftfail:
|
case api.AuthResultResultSoftfail:
|
||||||
return 17
|
return 17
|
||||||
default: // fail, temperror, permerror
|
default: // fail, temperror, permerror
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,44 +24,43 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSPFResult(t *testing.T) {
|
func TestParseSPFResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SPF pass with domain",
|
name: "SPF pass with domain",
|
||||||
part: "spf=pass smtp.mailfrom=sender@example.com",
|
part: "spf=pass smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail",
|
name: "SPF fail",
|
||||||
part: "spf=fail smtp.mailfrom=sender@example.com",
|
part: "spf=fail smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: model.AuthResultResultNeutral,
|
expectedResult: api.AuthResultResultNeutral,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: model.AuthResultResultSoftfail,
|
expectedResult: api.AuthResultResultSoftfail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -85,7 +84,7 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
receivedSPF string
|
receivedSPF string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDomain *string
|
expectedDomain *string
|
||||||
expectNil bool
|
expectNil bool
|
||||||
}{
|
}{
|
||||||
|
|
@ -98,8 +97,8 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
envelope-from="user@example.com";
|
envelope-from="user@example.com";
|
||||||
helo=smtp.example.com;
|
helo=smtp.example.com;
|
||||||
client-ip=192.0.2.10`,
|
client-ip=192.0.2.10`,
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: utils.PtrTo("example.com"),
|
expectedDomain: api.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail with sender",
|
name: "SPF fail with sender",
|
||||||
|
|
@ -110,43 +109,43 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
sender="sender@test.com";
|
sender="sender@test.com";
|
||||||
helo=smtp.test.com;
|
helo=smtp.test.com;
|
||||||
client-ip=192.0.2.20`,
|
client-ip=192.0.2.20`,
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDomain: utils.PtrTo("test.com"),
|
expectedDomain: api.PtrTo("test.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
|
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
|
||||||
expectedResult: model.AuthResultResultSoftfail,
|
expectedResult: api.AuthResultResultSoftfail,
|
||||||
expectedDomain: utils.PtrTo("example.org"),
|
expectedDomain: api.PtrTo("example.org"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
|
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
|
||||||
expectedResult: model.AuthResultResultNeutral,
|
expectedResult: api.AuthResultResultNeutral,
|
||||||
expectedDomain: utils.PtrTo("domain.net"),
|
expectedDomain: api.PtrTo("domain.net"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF none",
|
name: "SPF none",
|
||||||
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
|
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
|
||||||
expectedResult: model.AuthResultResultNone,
|
expectedResult: api.AuthResultResultNone,
|
||||||
expectedDomain: utils.PtrTo("company.io"),
|
expectedDomain: api.PtrTo("company.io"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF temperror",
|
name: "SPF temperror",
|
||||||
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
|
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
|
||||||
expectedResult: model.AuthResultResultTemperror,
|
expectedResult: api.AuthResultResultTemperror,
|
||||||
expectedDomain: utils.PtrTo("shop.example"),
|
expectedDomain: api.PtrTo("shop.example"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF permerror",
|
name: "SPF permerror",
|
||||||
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
|
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
|
||||||
expectedResult: model.AuthResultResultPermerror,
|
expectedResult: api.AuthResultResultPermerror,
|
||||||
expectedDomain: utils.PtrTo("invalid.test"),
|
expectedDomain: api.PtrTo("invalid.test"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF pass without domain extraction",
|
name: "SPF pass without domain extraction",
|
||||||
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
|
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: nil,
|
expectedDomain: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -157,12 +156,12 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "SPF with unquoted envelope-from",
|
name: "SPF with unquoted envelope-from",
|
||||||
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
|
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: utils.PtrTo("mail.example.net"),
|
expectedDomain: api.PtrTo("mail.example.net"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -24,84 +24,83 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetAuthenticationScore(t *testing.T) {
|
func TestGetAuthenticationScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
results *model.AuthenticationResults
|
results *api.AuthenticationResults
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
||||||
results: &model.AuthenticationResults{
|
results: &api.AuthenticationResults{
|
||||||
Spf: &model.AuthResult{
|
Spf: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
Dkim: &[]model.AuthResult{
|
Dkim: &[]api.AuthResult{
|
||||||
{Result: model.AuthResultResultPass},
|
{Result: api.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
Dmarc: &model.AuthResult{
|
Dmarc: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF and DKIM only",
|
name: "SPF and DKIM only",
|
||||||
results: &model.AuthenticationResults{
|
results: &api.AuthenticationResults{
|
||||||
Spf: &model.AuthResult{
|
Spf: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
Dkim: &[]model.AuthResult{
|
Dkim: &[]api.AuthResult{
|
||||||
{Result: model.AuthResultResultPass},
|
{Result: api.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 48, // SPF=25 + DKIM=23
|
expectedScore: 48, // SPF=25 + DKIM=23
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail, DKIM pass",
|
name: "SPF fail, DKIM pass",
|
||||||
results: &model.AuthenticationResults{
|
results: &api.AuthenticationResults{
|
||||||
Spf: &model.AuthResult{
|
Spf: &api.AuthResult{
|
||||||
Result: model.AuthResultResultFail,
|
Result: api.AuthResultResultFail,
|
||||||
},
|
},
|
||||||
Dkim: &[]model.AuthResult{
|
Dkim: &[]api.AuthResult{
|
||||||
{Result: model.AuthResultResultPass},
|
{Result: api.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 23, // SPF=0 + DKIM=23
|
expectedScore: 23, // SPF=0 + DKIM=23
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
results: &model.AuthenticationResults{
|
results: &api.AuthenticationResults{
|
||||||
Spf: &model.AuthResult{
|
Spf: &api.AuthResult{
|
||||||
Result: model.AuthResultResultSoftfail,
|
Result: api.AuthResultResultSoftfail,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 4,
|
expectedScore: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No authentication",
|
name: "No authentication",
|
||||||
results: &model.AuthenticationResults{},
|
results: &api.AuthenticationResults{},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI adds to score",
|
name: "BIMI adds to score",
|
||||||
results: &model.AuthenticationResults{
|
results: &api.AuthenticationResults{
|
||||||
Spf: &model.AuthResult{
|
Spf: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
Bimi: &model.AuthResult{
|
Bimi: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 35, // SPF (25) + BIMI (10)
|
expectedScore: 35, // SPF (25) + BIMI (10)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
scorer := NewAuthenticationAnalyzer("")
|
scorer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -118,30 +117,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
expectedSPFResult *model.AuthResultResult
|
expectedSPFResult *api.AuthResultResult
|
||||||
expectedSPFDomain *string
|
expectedSPFDomain *string
|
||||||
expectedDKIMCount int
|
expectedDKIMCount int
|
||||||
expectedDKIMResult *model.AuthResultResult
|
expectedDKIMResult *api.AuthResultResult
|
||||||
expectedDMARCResult *model.AuthResultResult
|
expectedDMARCResult *api.AuthResultResult
|
||||||
expectedDMARCDomain *string
|
expectedDMARCDomain *string
|
||||||
expectedBIMIResult *model.AuthResultResult
|
expectedBIMIResult *api.AuthResultResult
|
||||||
expectedARCResult *model.ARCResultResult
|
expectedARCResult *api.ARCResultResult
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Complete authentication results",
|
name: "Complete authentication results",
|
||||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
|
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCDomain: utils.PtrTo("example.com"),
|
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF only",
|
name: "SPF only",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
|
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedSPFDomain: utils.PtrTo("domain.com"),
|
expectedSPFDomain: api.PtrTo("domain.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
|
|
@ -150,68 +149,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
|
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Multiple DKIM signatures",
|
name: "Multiple DKIM signatures",
|
||||||
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
|
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 2,
|
expectedDKIMCount: 2,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail with DKIM pass",
|
name: "SPF fail with DKIM pass",
|
||||||
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
|
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultFail),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultFail),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
|
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DMARC fail",
|
name: "DMARC fail",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
|
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail),
|
expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
|
||||||
expectedDMARCDomain: utils.PtrTo("example.com"),
|
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI pass",
|
name: "BIMI pass",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
|
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC pass",
|
name: "ARC pass",
|
||||||
header: "mail.example.com; arc=pass",
|
header: "mail.example.com; arc=pass",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
|
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "All authentication methods",
|
name: "All authentication methods",
|
||||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
|
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedDMARCDomain: utils.PtrTo("example.com"),
|
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||||
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
|
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Empty header (authserv-id only)",
|
name: "Empty header (authserv-id only)",
|
||||||
|
|
@ -222,8 +221,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Empty parts with semicolons",
|
name: "Empty parts with semicolons",
|
||||||
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
|
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -231,28 +230,28 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
header: "mail.example.com; dkim=pass d=example.com s=selector1",
|
header: "mail.example.com; dkim=pass d=example.com s=selector1",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
|
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral),
|
||||||
expectedSPFDomain: utils.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF none",
|
name: "SPF none",
|
||||||
header: "mail.example.com; spf=none",
|
header: "mail.example.com; spf=none",
|
||||||
expectedSPFResult: utils.PtrTo(model.AuthResultResultNone),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||||
|
|
||||||
// Check SPF
|
// Check SPF
|
||||||
|
|
@ -354,17 +353,17 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
|
|
||||||
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
// This test verifies that only the first occurrence of each auth method is parsed
|
// This test verifies that only the first occurrence of each auth method is parsed
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Spf == nil {
|
if results.Spf == nil {
|
||||||
t.Fatal("Expected SPF result, got nil")
|
t.Fatal("Expected SPF result, got nil")
|
||||||
}
|
}
|
||||||
if results.Spf.Result != model.AuthResultResultPass {
|
if results.Spf.Result != api.AuthResultResultPass {
|
||||||
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
|
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
|
||||||
}
|
}
|
||||||
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
|
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
|
||||||
|
|
@ -374,13 +373,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
|
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Dmarc == nil {
|
if results.Dmarc == nil {
|
||||||
t.Fatal("Expected DMARC result, got nil")
|
t.Fatal("Expected DMARC result, got nil")
|
||||||
}
|
}
|
||||||
if results.Dmarc.Result != model.AuthResultResultPass {
|
if results.Dmarc.Result != api.AuthResultResultPass {
|
||||||
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
|
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
|
||||||
}
|
}
|
||||||
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
|
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
|
||||||
|
|
@ -390,26 +389,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; arc=pass; arc=fail"
|
header := "mail.example.com; arc=pass; arc=fail"
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Arc == nil {
|
if results.Arc == nil {
|
||||||
t.Fatal("Expected ARC result, got nil")
|
t.Fatal("Expected ARC result, got nil")
|
||||||
}
|
}
|
||||||
if results.Arc.Result != model.ARCResultResultPass {
|
if results.Arc.Result != api.ARCResultResultPass {
|
||||||
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
|
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
|
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Bimi == nil {
|
if results.Bimi == nil {
|
||||||
t.Fatal("Expected BIMI result, got nil")
|
t.Fatal("Expected BIMI result, got nil")
|
||||||
}
|
}
|
||||||
if results.Bimi.Result != model.AuthResultResultPass {
|
if results.Bimi.Result != api.AuthResultResultPass {
|
||||||
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
|
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
|
||||||
}
|
}
|
||||||
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
|
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
|
||||||
|
|
@ -420,7 +419,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
|
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
|
||||||
// DKIM is special - multiple signatures should all be collected
|
// DKIM is special - multiple signatures should all be collected
|
||||||
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
|
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
|
||||||
results := &model.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Dkim == nil {
|
if results.Dkim == nil {
|
||||||
|
|
@ -429,10 +428,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
if len(*results.Dkim) != 2 {
|
if len(*results.Dkim) != 2 {
|
||||||
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
|
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
|
||||||
}
|
}
|
||||||
if (*results.Dkim)[0].Result != model.AuthResultResultPass {
|
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
|
||||||
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
|
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
|
||||||
}
|
}
|
||||||
if (*results.Dkim)[1].Result != model.AuthResultResultFail {
|
if (*results.Dkim)[1].Result != api.AuthResultResultFail {
|
||||||
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
|
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -25,35 +25,34 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
|
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
|
||||||
// Example: x-aligned-from=pass (Address match)
|
// Example: x-aligned-from=pass (Address match)
|
||||||
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
|
||||||
result := &model.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
|
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract details (everything after the result)
|
// Extract details (everything after the result)
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
|
||||||
if results.XAlignedFrom != nil {
|
if results.XAlignedFrom != nil {
|
||||||
switch results.XAlignedFrom.Result {
|
switch results.XAlignedFrom.Result {
|
||||||
case model.AuthResultResultPass:
|
case api.AuthResultResultPass:
|
||||||
// pass: positive contribution
|
// pass: positive contribution
|
||||||
return 100
|
return 100
|
||||||
case model.AuthResultResultFail:
|
case api.AuthResultResultFail:
|
||||||
// fail: negative contribution
|
// fail: negative contribution
|
||||||
return 0
|
return 0
|
||||||
default:
|
default:
|
||||||
|
|
@ -62,5 +61,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.Authe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 100
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,49 +24,49 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseXAlignedFromResult(t *testing.T) {
|
func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDetail string
|
expectedDetail string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "x-aligned-from pass with details",
|
name: "x-aligned-from pass with details",
|
||||||
part: "x-aligned-from=pass (Address match)",
|
part: "x-aligned-from=pass (Address match)",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDetail: "pass (Address match)",
|
expectedDetail: "pass (Address match)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from fail with reason",
|
name: "x-aligned-from fail with reason",
|
||||||
part: "x-aligned-from=fail (Address mismatch)",
|
part: "x-aligned-from=fail (Address mismatch)",
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDetail: "fail (Address mismatch)",
|
expectedDetail: "fail (Address mismatch)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from pass minimal",
|
name: "x-aligned-from pass minimal",
|
||||||
part: "x-aligned-from=pass",
|
part: "x-aligned-from=pass",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDetail: "pass",
|
expectedDetail: "pass",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from neutral",
|
name: "x-aligned-from neutral",
|
||||||
part: "x-aligned-from=neutral (No alignment check performed)",
|
part: "x-aligned-from=neutral (No alignment check performed)",
|
||||||
expectedResult: model.AuthResultResultNeutral,
|
expectedResult: api.AuthResultResultNeutral,
|
||||||
expectedDetail: "neutral (No alignment check performed)",
|
expectedDetail: "neutral (No alignment check performed)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from none",
|
name: "x-aligned-from none",
|
||||||
part: "x-aligned-from=none",
|
part: "x-aligned-from=none",
|
||||||
expectedResult: model.AuthResultResultNone,
|
expectedResult: api.AuthResultResultNone,
|
||||||
expectedDetail: "none",
|
expectedDetail: "none",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
func TestCalculateXAlignedFromScore(t *testing.T) {
|
func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
result *model.AuthResult
|
result *api.AuthResult
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "pass result gives positive score",
|
name: "pass result gives positive score",
|
||||||
result: &model.AuthResult{
|
result: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
expectedScore: 100,
|
expectedScore: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fail result gives zero score",
|
name: "fail result gives zero score",
|
||||||
result: &model.AuthResult{
|
result: &api.AuthResult{
|
||||||
Result: model.AuthResultResultFail,
|
Result: api.AuthResultResultFail,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "neutral result gives zero score",
|
name: "neutral result gives zero score",
|
||||||
result: &model.AuthResult{
|
result: &api.AuthResult{
|
||||||
Result: model.AuthResultResultNeutral,
|
Result: api.AuthResultResultNeutral,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "none result gives zero score",
|
name: "none result gives zero score",
|
||||||
result: &model.AuthResult{
|
result: &api.AuthResult{
|
||||||
Result: model.AuthResultResultNone,
|
Result: api.AuthResultResultNone,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -126,11 +126,11 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
results := &model.AuthenticationResults{
|
results := &api.AuthenticationResults{
|
||||||
XAlignedFrom: tt.result,
|
XAlignedFrom: tt.result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
|
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
|
||||||
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
|
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
|
||||||
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult {
|
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult {
|
||||||
result := &model.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
|
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = model.AuthResultResult(resultStr)
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
// Extract domain (header.d or d)
|
||||||
|
|
@ -55,15 +54,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.Auth
|
||||||
result.Selector = &selector
|
result.Selector = &selector
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
|
||||||
if results.XGoogleDkim != nil {
|
if results.XGoogleDkim != nil {
|
||||||
switch results.XGoogleDkim.Result {
|
switch results.XGoogleDkim.Result {
|
||||||
case model.AuthResultResultPass:
|
case api.AuthResultResultPass:
|
||||||
// pass: don't alter the score
|
// pass: don't alter the score
|
||||||
default: // fail
|
default: // fail
|
||||||
return -100
|
return -100
|
||||||
|
|
|
||||||
|
|
@ -24,43 +24,43 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseXGoogleDKIMResult(t *testing.T) {
|
func TestParseXGoogleDKIMResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult model.AuthResultResult
|
expectedResult api.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
expectedSelector string
|
expectedSelector string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "x-google-dkim pass with domain",
|
name: "x-google-dkim pass with domain",
|
||||||
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
|
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "1e100.net",
|
expectedDomain: "1e100.net",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-google-dkim pass with short form",
|
name: "x-google-dkim pass with short form",
|
||||||
part: "x-google-dkim=pass d=gmail.com",
|
part: "x-google-dkim=pass d=gmail.com",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
expectedDomain: "gmail.com",
|
expectedDomain: "gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-google-dkim fail",
|
name: "x-google-dkim fail",
|
||||||
part: "x-google-dkim=fail header.d=example.com",
|
part: "x-google-dkim=fail header.d=example.com",
|
||||||
expectedResult: model.AuthResultResultFail,
|
expectedResult: api.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-google-dkim with minimal info",
|
name: "x-google-dkim with minimal info",
|
||||||
part: "x-google-dkim=pass",
|
part: "x-google-dkim=pass",
|
||||||
expectedResult: model.AuthResultResultPass,
|
expectedResult: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -27,22 +27,18 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentAnalyzer analyzes email content (HTML, links, images)
|
// ContentAnalyzer analyzes email content (HTML, links, images)
|
||||||
type ContentAnalyzer struct {
|
type ContentAnalyzer struct {
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
listUnsubscribeURLs []string // URLs from List-Unsubscribe header
|
|
||||||
hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContentAnalyzer creates a new content analyzer with configurable timeout
|
// NewContentAnalyzer creates a new content analyzer with configurable timeout
|
||||||
|
|
@ -114,13 +110,6 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
|
||||||
|
|
||||||
results.IsMultipart = len(email.Parts) > 1
|
results.IsMultipart = len(email.Parts) > 1
|
||||||
|
|
||||||
// Parse List-Unsubscribe header URLs for use in link detection
|
|
||||||
c.listUnsubscribeURLs = email.GetListUnsubscribeURLs()
|
|
||||||
|
|
||||||
// Check for one-click unsubscribe support
|
|
||||||
listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post")
|
|
||||||
c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click")
|
|
||||||
|
|
||||||
// Get HTML and text parts
|
// Get HTML and text parts
|
||||||
htmlParts := email.GetHTMLParts()
|
htmlParts := email.GetHTMLParts()
|
||||||
textParts := email.GetTextParts()
|
textParts := email.GetTextParts()
|
||||||
|
|
@ -342,14 +331,9 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
|
||||||
|
|
||||||
// isUnsubscribeLink checks if a link is an unsubscribe link
|
// isUnsubscribeLink checks if a link is an unsubscribe link
|
||||||
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
||||||
// First check: does the href match a URL from the List-Unsubscribe header?
|
|
||||||
if slices.Contains(c.listUnsubscribeURLs, href) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check href for unsubscribe keywords
|
// Check href for unsubscribe keywords
|
||||||
lowerHref := strings.ToLower(href)
|
lowerHref := strings.ToLower(href)
|
||||||
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"}
|
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
|
||||||
for _, keyword := range unsubKeywords {
|
for _, keyword := range unsubKeywords {
|
||||||
if strings.Contains(lowerHref, keyword) {
|
if strings.Contains(lowerHref, keyword) {
|
||||||
return true
|
return true
|
||||||
|
|
@ -455,8 +439,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
// Extract the actual destination domain/email based on scheme
|
// Extract the actual destination domain/email based on scheme
|
||||||
var actualDomain string
|
var actualDomain string
|
||||||
|
|
||||||
switch parsedURL.Scheme {
|
if parsedURL.Scheme == "mailto" {
|
||||||
case "mailto":
|
|
||||||
// Extract email address from mailto: URL
|
// Extract email address from mailto: URL
|
||||||
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
|
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
|
||||||
mailtoAddr := parsedURL.Opaque
|
mailtoAddr := parsedURL.Opaque
|
||||||
|
|
@ -474,8 +457,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
} else {
|
} else {
|
||||||
return false // Invalid mailto
|
return false // Invalid mailto
|
||||||
}
|
}
|
||||||
case "http":
|
} else if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
|
||||||
case "https":
|
|
||||||
// Check if URL has a host
|
// Check if URL has a host
|
||||||
if parsedURL.Host == "" {
|
if parsedURL.Host == "" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -487,7 +469,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
actualDomain = actualDomain[:idx]
|
actualDomain = actualDomain[:idx]
|
||||||
}
|
}
|
||||||
actualDomain = strings.ToLower(actualDomain)
|
actualDomain = strings.ToLower(actualDomain)
|
||||||
default:
|
} else {
|
||||||
// Skip checks for other URL schemes (tel, etc.)
|
// Skip checks for other URL schemes (tel, etc.)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -510,8 +492,10 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
"email us", "contact us", "send email", "get in touch", "reach out",
|
"email us", "contact us", "send email", "get in touch", "reach out",
|
||||||
"contact", "email", "write to us",
|
"contact", "email", "write to us",
|
||||||
}
|
}
|
||||||
if slices.Contains(genericTexts, linkText) {
|
for _, generic := range genericTexts {
|
||||||
return false
|
if linkText == generic {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain-like patterns from link text using regex
|
// Extract domain-like patterns from link text using regex
|
||||||
|
|
@ -578,8 +562,10 @@ func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) boo
|
||||||
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
||||||
"buff.ly", "is.gd", "bl.ink", "short.io",
|
"buff.ly", "is.gd", "bl.ink", "short.io",
|
||||||
}
|
}
|
||||||
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) {
|
for _, shortener := range shorteners {
|
||||||
return true
|
if strings.ToLower(parsedURL.Host) == shortener {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for excessive subdomains (possible obfuscation)
|
// Check for excessive subdomains (possible obfuscation)
|
||||||
|
|
@ -729,16 +715,15 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateContentAnalysis creates structured content analysis from results
|
// GenerateContentAnalysis creates structured content analysis from results
|
||||||
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis {
|
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis := &model.ContentAnalysis{
|
analysis := &api.ContentAnalysis{
|
||||||
HasHtml: utils.PtrTo(results.HTMLContent != ""),
|
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
||||||
HasPlaintext: utils.PtrTo(results.TextContent != ""),
|
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
||||||
HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe),
|
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
||||||
UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate text-to-image ratio (inverse of image-to-text)
|
// Calculate text-to-image ratio (inverse of image-to-text)
|
||||||
|
|
@ -751,16 +736,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build HTML issues
|
// Build HTML issues
|
||||||
htmlIssues := []model.ContentIssue{}
|
htmlIssues := []api.ContentIssue{}
|
||||||
|
|
||||||
// Add HTML parsing errors
|
// Add HTML parsing errors
|
||||||
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
|
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
|
||||||
for _, errMsg := range results.HTMLErrors {
|
for _, errMsg := range results.HTMLErrors {
|
||||||
htmlIssues = append(htmlIssues, model.ContentIssue{
|
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||||
Type: model.BrokenHtml,
|
Type: api.BrokenHtml,
|
||||||
Severity: model.ContentIssueSeverityHigh,
|
Severity: api.ContentIssueSeverityHigh,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
|
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -774,53 +759,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if missingAltCount > 0 {
|
if missingAltCount > 0 {
|
||||||
htmlIssues = append(htmlIssues, model.ContentIssue{
|
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||||
Type: model.MissingAlt,
|
Type: api.MissingAlt,
|
||||||
Severity: model.ContentIssueSeverityMedium,
|
Severity: api.ContentIssueSeverityMedium,
|
||||||
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
|
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
|
||||||
Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
|
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add excessive images issue
|
// Add excessive images issue
|
||||||
if results.ImageTextRatio > 10.0 {
|
if results.ImageTextRatio > 10.0 {
|
||||||
htmlIssues = append(htmlIssues, model.ContentIssue{
|
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||||
Type: model.ExcessiveImages,
|
Type: api.ExcessiveImages,
|
||||||
Severity: model.ContentIssueSeverityMedium,
|
Severity: api.ContentIssueSeverityMedium,
|
||||||
Message: "Email is excessively image-heavy",
|
Message: "Email is excessively image-heavy",
|
||||||
Advice: utils.PtrTo("Reduce the number of images relative to text content"),
|
Advice: api.PtrTo("Reduce the number of images relative to text content"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add suspicious URL issues
|
// Add suspicious URL issues
|
||||||
for _, suspURL := range results.SuspiciousURLs {
|
for _, suspURL := range results.SuspiciousURLs {
|
||||||
htmlIssues = append(htmlIssues, model.ContentIssue{
|
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||||
Type: model.SuspiciousLink,
|
Type: api.SuspiciousLink,
|
||||||
Severity: model.ContentIssueSeverityHigh,
|
Severity: api.ContentIssueSeverityHigh,
|
||||||
Message: "Suspicious URL detected",
|
Message: "Suspicious URL detected",
|
||||||
Location: &suspURL,
|
Location: &suspURL,
|
||||||
Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
|
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add harmful HTML tag issues
|
// Add harmful HTML tag issues
|
||||||
for _, harmfulIssue := range results.HarmfullIssues {
|
for _, harmfulIssue := range results.HarmfullIssues {
|
||||||
htmlIssues = append(htmlIssues, model.ContentIssue{
|
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||||
Type: model.DangerousHtml,
|
Type: api.DangerousHtml,
|
||||||
Severity: model.ContentIssueSeverityCritical,
|
Severity: api.ContentIssueSeverityCritical,
|
||||||
Message: harmfulIssue,
|
Message: harmfulIssue,
|
||||||
Advice: utils.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
|
Advice: api.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add general content issues (like external stylesheets)
|
// Add general content issues (like external stylesheets)
|
||||||
for _, contentIssue := range results.ContentIssues {
|
for _, contentIssue := range results.ContentIssues {
|
||||||
htmlIssues = append(htmlIssues, model.ContentIssue{
|
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||||
Type: model.BrokenHtml,
|
Type: api.BrokenHtml,
|
||||||
Severity: model.ContentIssueSeverityLow,
|
Severity: api.ContentIssueSeverityLow,
|
||||||
Message: contentIssue,
|
Message: contentIssue,
|
||||||
Advice: utils.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
|
Advice: api.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -830,31 +815,31 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
|
||||||
|
|
||||||
// Convert links
|
// Convert links
|
||||||
if len(results.Links) > 0 {
|
if len(results.Links) > 0 {
|
||||||
links := make([]model.LinkCheck, 0, len(results.Links))
|
links := make([]api.LinkCheck, 0, len(results.Links))
|
||||||
for _, link := range results.Links {
|
for _, link := range results.Links {
|
||||||
status := model.Valid
|
status := api.Valid
|
||||||
if link.Status >= 400 {
|
if link.Status >= 400 {
|
||||||
status = model.Broken
|
status = api.Broken
|
||||||
} else if !link.IsSafe {
|
} else if !link.IsSafe {
|
||||||
status = model.Suspicious
|
status = api.Suspicious
|
||||||
} else if link.Warning != "" {
|
} else if link.Warning != "" {
|
||||||
status = model.Timeout
|
status = api.Timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
apiLink := model.LinkCheck{
|
apiLink := api.LinkCheck{
|
||||||
Url: link.URL,
|
Url: link.URL,
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
|
|
||||||
if link.Status > 0 {
|
if link.Status > 0 {
|
||||||
apiLink.HttpCode = utils.PtrTo(link.Status)
|
apiLink.HttpCode = api.PtrTo(link.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a URL shortener
|
// Check if it's a URL shortener
|
||||||
parsedURL, err := url.Parse(link.URL)
|
parsedURL, err := url.Parse(link.URL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
|
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
|
||||||
apiLink.IsShortened = utils.PtrTo(isShortened)
|
apiLink.IsShortened = api.PtrTo(isShortened)
|
||||||
}
|
}
|
||||||
|
|
||||||
links = append(links, apiLink)
|
links = append(links, apiLink)
|
||||||
|
|
@ -864,9 +849,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
|
||||||
|
|
||||||
// Convert images
|
// Convert images
|
||||||
if len(results.Images) > 0 {
|
if len(results.Images) > 0 {
|
||||||
images := make([]model.ImageCheck, 0, len(results.Images))
|
images := make([]api.ImageCheck, 0, len(results.Images))
|
||||||
for _, img := range results.Images {
|
for _, img := range results.Images {
|
||||||
apiImg := model.ImageCheck{
|
apiImg := api.ImageCheck{
|
||||||
HasAlt: img.HasAlt,
|
HasAlt: img.HasAlt,
|
||||||
}
|
}
|
||||||
if img.Src != "" {
|
if img.Src != "" {
|
||||||
|
|
@ -876,7 +861,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
|
||||||
apiImg.AltText = &img.AltText
|
apiImg.AltText = &img.AltText
|
||||||
}
|
}
|
||||||
// Simple heuristic: tracking pixels are typically 1x1
|
// Simple heuristic: tracking pixels are typically 1x1
|
||||||
apiImg.IsTrackingPixel = utils.PtrTo(false)
|
apiImg.IsTrackingPixel = api.PtrTo(false)
|
||||||
|
|
||||||
images = append(images, apiImg)
|
images = append(images, apiImg)
|
||||||
}
|
}
|
||||||
|
|
@ -885,19 +870,8 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
|
||||||
|
|
||||||
// Unsubscribe methods
|
// Unsubscribe methods
|
||||||
if results.HasUnsubscribe {
|
if results.HasUnsubscribe {
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Link)
|
methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
|
||||||
}
|
analysis.UnsubscribeMethods = &methods
|
||||||
|
|
||||||
for _, url := range c.listUnsubscribeURLs {
|
|
||||||
if strings.HasPrefix(url, "mailto:") {
|
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Mailto)
|
|
||||||
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
|
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
|
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.OneClick)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return analysis
|
return analysis
|
||||||
|
|
|
||||||
|
|
@ -144,74 +144,6 @@ func TestIsUnsubscribeLink(t *testing.T) {
|
||||||
linkText: "Read more",
|
linkText: "Read more",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
// Multilingual keyword detection - URL path
|
|
||||||
{
|
|
||||||
name: "German abmelden in URL",
|
|
||||||
href: "https://example.com/abmelden?id=42",
|
|
||||||
linkText: "Click here",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
|
|
||||||
href: "https://example.com/se-desabonner?id=42",
|
|
||||||
linkText: "Click here",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
// Multilingual keyword detection - link text
|
|
||||||
{
|
|
||||||
name: "German Abmelden in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=de",
|
|
||||||
linkText: "Abmelden",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "French Se désabonner in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=fr",
|
|
||||||
linkText: "Se désabonner",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Russian Отписаться in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=ru",
|
|
||||||
linkText: "Отписаться",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Chinese 退订 in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=zh",
|
|
||||||
linkText: "退订",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Japanese 登録を取り消す in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=ja",
|
|
||||||
linkText: "登録を取り消す",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Korean 구독 해지 in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=ko",
|
|
||||||
linkText: "구독 해지",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Dutch Uitschrijven in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=nl",
|
|
||||||
linkText: "Uitschrijven",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Polish Odsubskrybuj in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=pl",
|
|
||||||
linkText: "Odsubskrybuj",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Turkish Üyeliği sonlandır in link text",
|
|
||||||
href: "https://example.com/manage?id=42&lang=tr",
|
|
||||||
linkText: "Üyeliği sonlandır",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSAnalyzer analyzes DNS records for email domains
|
// DNSAnalyzer analyzes DNS records for email domains
|
||||||
|
|
@ -54,16 +54,16 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeDNS performs DNS validation for the email's domain
|
// AnalyzeDNS performs DNS validation for the email's domain
|
||||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
||||||
// Extract domain from From address
|
// Extract domain from From address
|
||||||
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
||||||
return &model.DNSResults{
|
return &api.DNSResults{
|
||||||
Errors: &[]string{"Unable to extract domain from email"},
|
Errors: &[]string{"Unable to extract domain from email"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fromDomain := *headersResults.DomainAlignment.FromDomain
|
fromDomain := *headersResults.DomainAlignment.FromDomain
|
||||||
|
|
||||||
results := &model.DNSResults{
|
results := &api.DNSResults{
|
||||||
FromDomain: fromDomain,
|
FromDomain: fromDomain,
|
||||||
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
|
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
|
||||||
}
|
}
|
||||||
|
|
@ -104,14 +104,19 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
|
||||||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||||
|
|
||||||
// Check DKIM records by parsing DKIM-Signature headers directly
|
// Check DKIM records (from authentication results)
|
||||||
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
|
// DKIM can be for any domain, but typically the From domain
|
||||||
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
if authResults != nil && authResults.Dkim != nil {
|
||||||
if dkimRecord != nil {
|
for _, dkim := range *authResults.Dkim {
|
||||||
if results.DkimRecords == nil {
|
if dkim.Domain != nil && dkim.Selector != nil {
|
||||||
results.DkimRecords = new([]model.DKIMRecord)
|
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
||||||
|
if dkimRecord != nil {
|
||||||
|
if results.DkimRecords == nil {
|
||||||
|
results.DkimRecords = new([]api.DKIMRecord)
|
||||||
|
}
|
||||||
|
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,13 +127,19 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
|
||||||
// Check BIMI record (for From domain - branding is based on visible sender)
|
// Check BIMI record (for From domain - branding is based on visible sender)
|
||||||
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
|
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
|
||||||
|
|
||||||
|
// Check DNSSEC status (for From domain)
|
||||||
|
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, fromDomain)
|
||||||
|
if err == nil {
|
||||||
|
results.DnssecEnabled = &dnssecEnabled
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeDomainOnly performs DNS validation for a domain without email context
|
// AnalyzeDomainOnly performs DNS validation for a domain without email context
|
||||||
// This is useful for checking domain configuration without sending an actual email
|
// This is useful for checking domain configuration without sending an actual email
|
||||||
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
|
||||||
results := &model.DNSResults{
|
results := &api.DNSResults{
|
||||||
FromDomain: domain,
|
FromDomain: domain,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,13 +155,19 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
|
||||||
// Check BIMI record with default selector
|
// Check BIMI record with default selector
|
||||||
results.BimiRecord = d.checkBIMIRecord(domain, "default")
|
results.BimiRecord = d.checkBIMIRecord(domain, "default")
|
||||||
|
|
||||||
|
// Check DNSSEC status
|
||||||
|
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, domain)
|
||||||
|
if err == nil {
|
||||||
|
results.DnssecEnabled = &dnssecEnabled
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
|
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
|
||||||
// Returns a score from 0-100 where higher is better
|
// Returns a score from 0-100 where higher is better
|
||||||
// This version excludes PTR and DKIM checks since they require email context
|
// This version excludes PTR and DKIM checks since they require email context
|
||||||
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) {
|
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
@ -192,18 +209,23 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
|
||||||
// CalculateDNSScore calculates the DNS score from records results
|
// CalculateDNSScore calculates the DNS score from records results
|
||||||
// Returns a score from 0-100 where higher is better
|
// Returns a score from 0-100 where higher is better
|
||||||
// senderIP is the original sender IP address used for FCrDNS verification
|
// senderIP is the original sender IP address used for FCrDNS verification
|
||||||
func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) {
|
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
score := 0
|
score := 0
|
||||||
|
|
||||||
|
// DNSSEC: 10 points
|
||||||
|
if results.DnssecEnabled != nil && *results.DnssecEnabled {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
|
||||||
// PTR and Forward DNS: 20 points
|
// PTR and Forward DNS: 20 points
|
||||||
score += 20 * d.calculatePTRScore(results, senderIP) / 100
|
score += 20 * d.calculatePTRScore(results, senderIP) / 100
|
||||||
|
|
||||||
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
|
// MX Records: 10 points (5 for From domain, 5 for Return-Path domain)
|
||||||
score += 20 * d.calculateMXScore(results) / 100
|
score += 10 * d.calculateMXScore(results) / 100
|
||||||
|
|
||||||
// SPF Records: 20 points
|
// SPF Records: 20 points
|
||||||
score += 20 * d.calculateSPFScore(results) / 100
|
score += 20 * d.calculateSPFScore(results) / 100
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,11 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
||||||
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord {
|
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
|
||||||
// BIMI records are at: selector._bimi.domain
|
// BIMI records are at: selector._bimi.domain
|
||||||
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
||||||
|
|
||||||
|
|
@ -41,20 +40,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.BIMIRecord{
|
return &api.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(txtRecords) == 0 {
|
if len(txtRecords) == 0 {
|
||||||
return &model.BIMIRecord{
|
return &api.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No BIMI record found"),
|
Error: api.PtrTo("No BIMI record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,18 +66,18 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord
|
||||||
|
|
||||||
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
||||||
if !d.validateBIMI(bimiRecord) {
|
if !d.validateBIMI(bimiRecord) {
|
||||||
return &model.BIMIRecord{
|
return &api.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: &bimiRecord,
|
Record: &bimiRecord,
|
||||||
LogoUrl: &logoURL,
|
LogoUrl: &logoURL,
|
||||||
VmcUrl: &vmcURL,
|
VmcUrl: &vmcURL,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("BIMI record appears malformed"),
|
Error: api.PtrTo("BIMI record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.BIMIRecord{
|
return &api.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: &bimiRecord,
|
Record: &bimiRecord,
|
||||||
|
|
|
||||||
|
|
@ -26,44 +26,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
|
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
||||||
type DKIMHeader struct {
|
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||||
Domain string
|
|
||||||
Selector string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values.
|
|
||||||
func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
|
||||||
var results []DKIMHeader
|
|
||||||
for _, sig := range signatures {
|
|
||||||
var domain, selector string
|
|
||||||
for _, part := range strings.Split(sig, ";") {
|
|
||||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
|
||||||
if len(kv) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(kv[0])
|
|
||||||
val := strings.TrimSpace(kv[1])
|
|
||||||
switch key {
|
|
||||||
case "d":
|
|
||||||
domain = val
|
|
||||||
case "s":
|
|
||||||
selector = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if domain != "" && selector != "" {
|
|
||||||
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector
|
|
||||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord {
|
|
||||||
// DKIM records are at: selector._domainkey.domain
|
// DKIM records are at: selector._domainkey.domain
|
||||||
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
||||||
|
|
||||||
|
|
@ -72,20 +39,20 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.DKIMRecord{
|
return &api.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(txtRecords) == 0 {
|
if len(txtRecords) == 0 {
|
||||||
return &model.DKIMRecord{
|
return &api.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No DKIM record found"),
|
Error: api.PtrTo("No DKIM record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,16 +61,16 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
|
||||||
|
|
||||||
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
||||||
if !d.validateDKIM(dkimRecord) {
|
if !d.validateDKIM(dkimRecord) {
|
||||||
return &model.DKIMRecord{
|
return &api.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: utils.PtrTo(dkimRecord),
|
Record: api.PtrTo(dkimRecord),
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("DKIM record appears malformed"),
|
Error: api.PtrTo("DKIM record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.DKIMRecord{
|
return &api.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: &dkimRecord,
|
Record: &dkimRecord,
|
||||||
|
|
@ -127,7 +94,7 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) {
|
||||||
// DKIM provides strong email authentication
|
// DKIM provides strong email authentication
|
||||||
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
||||||
hasValidDKIM := false
|
hasValidDKIM := false
|
||||||
|
|
|
||||||
|
|
@ -26,220 +26,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseDKIMSignatures(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
signatures []string
|
|
||||||
expected []DKIMHeader
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty input",
|
|
||||||
signatures: nil,
|
|
||||||
expected: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty string",
|
|
||||||
signatures: []string{""},
|
|
||||||
expected: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Simple Gmail-style",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Microsoft 365 style",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Tab-folded multiline (Postfix-style)",
|
|
||||||
signatures: []string{
|
|
||||||
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Space-folded multiline (RFC-style)",
|
|
||||||
signatures: []string{
|
|
||||||
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "d= and s= on separate continuation lines",
|
|
||||||
signatures: []string{
|
|
||||||
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No space after semicolons",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple spaces after semicolons",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ed25519 signature (RFC 8463)",
|
|
||||||
signatures: []string{
|
|
||||||
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple signatures (ESP double-signing)",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{
|
|
||||||
{Domain: "mydomain.com", Selector: "mail"},
|
|
||||||
{Domain: "sendib.com", Selector: "mail"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{
|
|
||||||
{Domain: "football.example.com", Selector: "brisbane"},
|
|
||||||
{Domain: "football.example.com", Selector: "test"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Amazon SES long selectors",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{
|
|
||||||
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
|
|
||||||
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Subdomain in d=",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Deeply nested subdomain",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Selector with hyphens (Microsoft 365 custom domain style)",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Selector with dots",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Single-character selector",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Postmark-style timestamp selector, s= before d=",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "d= and s= at the very end",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Full tag set",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing d= tag",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing s= tag",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing both d= and s= tags",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mix of valid and invalid signatures",
|
|
||||||
signatures: []string{
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
|
|
||||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
|
|
||||||
},
|
|
||||||
expected: []DKIMHeader{
|
|
||||||
{Domain: "good.com", Selector: "sel1"},
|
|
||||||
{Domain: "also-good.com", Selector: "sel2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := parseDKIMSignatures(tt.signatures)
|
|
||||||
if len(result) != len(tt.expected) {
|
|
||||||
t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
|
|
||||||
}
|
|
||||||
for i := range tt.expected {
|
|
||||||
if result[i].Domain != tt.expected[i].Domain {
|
|
||||||
t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
|
|
||||||
}
|
|
||||||
if result[i].Selector != tt.expected[i].Selector {
|
|
||||||
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDKIM(t *testing.T) {
|
func TestValidateDKIM(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,11 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkmodel.DMARCRecord looks up and validates DMARC record for a domain
|
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
|
||||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
||||||
// DMARC records are at: _dmarc.domain
|
// DMARC records are at: _dmarc.domain
|
||||||
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
||||||
|
|
||||||
|
|
@ -41,9 +40,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.DMARCRecord{
|
return &api.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,9 +56,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
}
|
}
|
||||||
|
|
||||||
if dmarcRecord == "" {
|
if dmarcRecord == "" {
|
||||||
return &model.DMARCRecord{
|
return &api.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No DMARC record found"),
|
Error: api.PtrTo("No DMARC record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,21 +77,21 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if !d.validateDMARC(dmarcRecord) {
|
if !d.validateDMARC(dmarcRecord) {
|
||||||
return &model.DMARCRecord{
|
return &api.DMARCRecord{
|
||||||
Record: &dmarcRecord,
|
Record: &dmarcRecord,
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
SpfAlignment: spfAlignment,
|
SpfAlignment: spfAlignment,
|
||||||
DkimAlignment: dkimAlignment,
|
DkimAlignment: dkimAlignment,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("DMARC record appears malformed"),
|
Error: api.PtrTo("DMARC record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.DMARCRecord{
|
return &api.DMARCRecord{
|
||||||
Record: &dmarcRecord,
|
Record: &dmarcRecord,
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
SpfAlignment: spfAlignment,
|
SpfAlignment: spfAlignment,
|
||||||
|
|
@ -114,44 +113,44 @@ func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||||
|
|
||||||
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
|
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
|
||||||
// Returns "relaxed" (default) or "strict"
|
// Returns "relaxed" (default) or "strict"
|
||||||
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment {
|
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment {
|
||||||
// Look for aspf=s (strict) or aspf=r (relaxed)
|
// Look for aspf=s (strict) or aspf=r (relaxed)
|
||||||
re := regexp.MustCompile(`aspf=(r|s)`)
|
re := regexp.MustCompile(`aspf=(r|s)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
if matches[1] == "s" {
|
if matches[1] == "s" {
|
||||||
return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
|
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
|
||||||
}
|
}
|
||||||
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
|
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
// Default is relaxed if not specified
|
// Default is relaxed if not specified
|
||||||
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
|
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
|
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
|
||||||
// Returns "relaxed" (default) or "strict"
|
// Returns "relaxed" (default) or "strict"
|
||||||
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment {
|
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
|
||||||
// Look for adkim=s (strict) or adkim=r (relaxed)
|
// Look for adkim=s (strict) or adkim=r (relaxed)
|
||||||
re := regexp.MustCompile(`adkim=(r|s)`)
|
re := regexp.MustCompile(`adkim=(r|s)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
if matches[1] == "s" {
|
if matches[1] == "s" {
|
||||||
return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
|
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
|
||||||
}
|
}
|
||||||
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
|
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
// Default is relaxed if not specified
|
// Default is relaxed if not specified
|
||||||
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
|
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
|
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
|
||||||
// Returns the sp tag value or nil if not specified (defaults to main policy)
|
// Returns the sp tag value or nil if not specified (defaults to main policy)
|
||||||
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy {
|
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
|
||||||
// Look for sp=none, sp=quarantine, or sp=reject
|
// Look for sp=none, sp=quarantine, or sp=reject
|
||||||
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
|
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1]))
|
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
|
||||||
}
|
}
|
||||||
// If sp is not specified, it defaults to the main policy (p tag)
|
// If sp is not specified, it defaults to the main policy (p tag)
|
||||||
// Return nil to indicate it's using the default
|
// Return nil to indicate it's using the default
|
||||||
|
|
@ -192,7 +191,7 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
|
||||||
// DMARC ties SPF and DKIM together and provides policy
|
// DMARC ties SPF and DKIM together and provides policy
|
||||||
if results.DmarcRecord != nil {
|
if results.DmarcRecord != nil {
|
||||||
if results.DmarcRecord.Valid {
|
if results.DmarcRecord.Valid {
|
||||||
|
|
@ -211,10 +210,10 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bonus points for strict alignment modes (2 points each)
|
// Bonus points for strict alignment modes (2 points each)
|
||||||
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
// Subdomain policy scoring (sp tag)
|
// Subdomain policy scoring (sp tag)
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractDMARCPolicy(t *testing.T) {
|
func TestExtractDMARCPolicy(t *testing.T) {
|
||||||
|
|
@ -229,17 +228,17 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Subdomain policy - none",
|
name: "Subdomain policy - none",
|
||||||
record: "v=DMARC1; p=quarantine; sp=none",
|
record: "v=DMARC1; p=quarantine; sp=none",
|
||||||
expectedPolicy: utils.PtrTo("none"),
|
expectedPolicy: api.PtrTo("none"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Subdomain policy - quarantine",
|
name: "Subdomain policy - quarantine",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine",
|
record: "v=DMARC1; p=reject; sp=quarantine",
|
||||||
expectedPolicy: utils.PtrTo("quarantine"),
|
expectedPolicy: api.PtrTo("quarantine"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Subdomain policy - reject",
|
name: "Subdomain policy - reject",
|
||||||
record: "v=DMARC1; p=quarantine; sp=reject",
|
record: "v=DMARC1; p=quarantine; sp=reject",
|
||||||
expectedPolicy: utils.PtrTo("reject"),
|
expectedPolicy: api.PtrTo("reject"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No subdomain policy specified (defaults to main policy)",
|
name: "No subdomain policy specified (defaults to main policy)",
|
||||||
|
|
@ -249,7 +248,7 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Complex record with subdomain policy",
|
name: "Complex record with subdomain policy",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
|
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
|
||||||
expectedPolicy: utils.PtrTo("quarantine"),
|
expectedPolicy: api.PtrTo("quarantine"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,22 +282,22 @@ func TestExtractDMARCPercentage(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Percentage - 100",
|
name: "Percentage - 100",
|
||||||
record: "v=DMARC1; p=quarantine; pct=100",
|
record: "v=DMARC1; p=quarantine; pct=100",
|
||||||
expectedPercentage: utils.PtrTo(100),
|
expectedPercentage: api.PtrTo(100),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Percentage - 50",
|
name: "Percentage - 50",
|
||||||
record: "v=DMARC1; p=quarantine; pct=50",
|
record: "v=DMARC1; p=quarantine; pct=50",
|
||||||
expectedPercentage: utils.PtrTo(50),
|
expectedPercentage: api.PtrTo(50),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Percentage - 25",
|
name: "Percentage - 25",
|
||||||
record: "v=DMARC1; p=reject; pct=25",
|
record: "v=DMARC1; p=reject; pct=25",
|
||||||
expectedPercentage: utils.PtrTo(25),
|
expectedPercentage: api.PtrTo(25),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Percentage - 0",
|
name: "Percentage - 0",
|
||||||
record: "v=DMARC1; p=none; pct=0",
|
record: "v=DMARC1; p=none; pct=0",
|
||||||
expectedPercentage: utils.PtrTo(0),
|
expectedPercentage: api.PtrTo(0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No percentage specified (defaults to 100)",
|
name: "No percentage specified (defaults to 100)",
|
||||||
|
|
@ -308,7 +307,7 @@ func TestExtractDMARCPercentage(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Complex record with percentage",
|
name: "Complex record with percentage",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
|
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
|
||||||
expectedPercentage: utils.PtrTo(75),
|
expectedPercentage: api.PtrTo(75),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid percentage > 100 (ignored)",
|
name: "Invalid percentage > 100 (ignored)",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
|
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
|
||||||
|
|
@ -63,7 +63,7 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
|
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
|
||||||
func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) {
|
func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) {
|
||||||
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
|
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
|
||||||
// 50 points for having PTR records
|
// 50 points for having PTR records
|
||||||
score += 50
|
score += 50
|
||||||
|
|
|
||||||
|
|
@ -25,37 +25,36 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkMXRecords looks up MX records for a domain
|
// checkMXRecords looks up MX records for a domain
|
||||||
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
|
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &[]model.MXRecord{
|
return &[]api.MXRecord{
|
||||||
{
|
{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(mxRecords) == 0 {
|
if len(mxRecords) == 0 {
|
||||||
return &[]model.MXRecord{
|
return &[]api.MXRecord{
|
||||||
{
|
{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No MX records found"),
|
Error: api.PtrTo("No MX records found"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []model.MXRecord
|
var results []api.MXRecord
|
||||||
for _, mx := range mxRecords {
|
for _, mx := range mxRecords {
|
||||||
results = append(results, model.MXRecord{
|
results = append(results, api.MXRecord{
|
||||||
Host: mx.Host,
|
Host: mx.Host,
|
||||||
Priority: mx.Pref,
|
Priority: mx.Pref,
|
||||||
Valid: true,
|
Valid: true,
|
||||||
|
|
@ -65,7 +64,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
|
||||||
return &results
|
return &results
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateMXScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) {
|
||||||
// Having valid MX records is critical for email deliverability
|
// Having valid MX records is critical for email deliverability
|
||||||
// From domain MX records (half points) - needed for replies
|
// From domain MX records (half points) - needed for replies
|
||||||
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,12 @@ package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/peterzen/goresolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSResolver defines the interface for DNS resolution operations.
|
// DNSResolver defines the interface for DNS resolution operations.
|
||||||
|
|
@ -43,38 +48,190 @@ type DNSResolver interface {
|
||||||
// LookupHost looks up the given hostname using the local resolver.
|
// LookupHost looks up the given hostname using the local resolver.
|
||||||
// It returns a slice of that host's addresses (IPv4 and IPv6).
|
// It returns a slice of that host's addresses (IPv4 and IPv6).
|
||||||
LookupHost(ctx context.Context, host string) ([]string, error)
|
LookupHost(ctx context.Context, host string) ([]string, error)
|
||||||
|
|
||||||
|
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
|
||||||
|
// Returns true if the domain has DNSSEC configured and the chain of trust is valid.
|
||||||
|
IsDNSSECEnabled(ctx context.Context, domain string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver.
|
// StandardDNSResolver is the default DNS resolver implementation that uses goresolver with DNSSEC validation.
|
||||||
type StandardDNSResolver struct {
|
type StandardDNSResolver struct {
|
||||||
resolver *net.Resolver
|
resolver *goresolver.Resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings.
|
// NewStandardDNSResolver creates a new StandardDNSResolver with DNSSEC validation support.
|
||||||
func NewStandardDNSResolver() DNSResolver {
|
func NewStandardDNSResolver() DNSResolver {
|
||||||
|
// Pass /etc/resolv.conf to load default DNS configuration
|
||||||
|
resolver, err := goresolver.NewResolver("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to initialize goresolver: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
return &StandardDNSResolver{
|
return &StandardDNSResolver{
|
||||||
resolver: &net.Resolver{
|
resolver: resolver,
|
||||||
PreferGo: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupMX implements DNSResolver.LookupMX using net.Resolver.
|
// LookupMX implements DNSResolver.LookupMX using goresolver with DNSSEC validation.
|
||||||
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
|
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
|
||||||
return r.resolver.LookupMX(ctx, name)
|
// Ensure the name ends with a dot for DNS queries
|
||||||
|
queryName := name
|
||||||
|
if !strings.HasSuffix(queryName, ".") {
|
||||||
|
queryName = queryName + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeMX)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mxRecords := make([]*net.MX, 0, len(rrs))
|
||||||
|
for _, rr := range rrs {
|
||||||
|
if mx, ok := rr.(*dns.MX); ok {
|
||||||
|
mxRecords = append(mxRecords, &net.MX{
|
||||||
|
Host: strings.TrimSuffix(mx.Mx, "."),
|
||||||
|
Pref: mx.Preference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mxRecords) == 0 {
|
||||||
|
return nil, fmt.Errorf("no MX records found for %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mxRecords, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver.
|
// LookupTXT implements DNSResolver.LookupTXT using goresolver with DNSSEC validation.
|
||||||
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||||
return r.resolver.LookupTXT(ctx, name)
|
// Ensure the name ends with a dot for DNS queries
|
||||||
|
queryName := name
|
||||||
|
if !strings.HasSuffix(queryName, ".") {
|
||||||
|
queryName = queryName + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeTXT)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
txtRecords := make([]string, 0, len(rrs))
|
||||||
|
for _, rr := range rrs {
|
||||||
|
if txt, ok := rr.(*dns.TXT); ok {
|
||||||
|
// Join all TXT strings (a single TXT record can have multiple strings)
|
||||||
|
txtRecords = append(txtRecords, strings.Join(txt.Txt, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(txtRecords) == 0 {
|
||||||
|
return nil, fmt.Errorf("no TXT records found for %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txtRecords, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver.
|
// LookupAddr implements DNSResolver.LookupAddr using goresolver with DNSSEC validation.
|
||||||
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
|
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
|
||||||
return r.resolver.LookupAddr(ctx, addr)
|
// Convert IP address to reverse DNS name (e.g., 1.0.0.127.in-addr.arpa.)
|
||||||
|
arpa, err := dns.ReverseAddr(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid IP address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rrs, err := r.resolver.StrictNSQuery(arpa, dns.TypePTR)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ptrRecords := make([]string, 0, len(rrs))
|
||||||
|
for _, rr := range rrs {
|
||||||
|
if ptr, ok := rr.(*dns.PTR); ok {
|
||||||
|
ptrRecords = append(ptrRecords, strings.TrimSuffix(ptr.Ptr, "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ptrRecords) == 0 {
|
||||||
|
return nil, fmt.Errorf("no PTR records found for %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ptrRecords, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupHost implements DNSResolver.LookupHost using net.Resolver.
|
// LookupHost implements DNSResolver.LookupHost using goresolver with DNSSEC validation.
|
||||||
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
|
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
|
||||||
return r.resolver.LookupHost(ctx, host)
|
// Ensure the host ends with a dot for DNS queries
|
||||||
|
queryName := host
|
||||||
|
if !strings.HasSuffix(queryName, ".") {
|
||||||
|
queryName = queryName + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
var allAddrs []string
|
||||||
|
|
||||||
|
// Query A records (IPv4)
|
||||||
|
rrsA, errA := r.resolver.StrictNSQuery(queryName, dns.TypeA)
|
||||||
|
if errA == nil {
|
||||||
|
for _, rr := range rrsA {
|
||||||
|
if a, ok := rr.(*dns.A); ok {
|
||||||
|
allAddrs = append(allAddrs, a.A.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query AAAA records (IPv6)
|
||||||
|
rrsAAAA, errAAAA := r.resolver.StrictNSQuery(queryName, dns.TypeAAAA)
|
||||||
|
if errAAAA == nil {
|
||||||
|
for _, rr := range rrsAAAA {
|
||||||
|
if aaaa, ok := rr.(*dns.AAAA); ok {
|
||||||
|
allAddrs = append(allAddrs, aaaa.AAAA.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return error only if both queries failed
|
||||||
|
if errA != nil && errAAAA != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve host: IPv4 error: %v, IPv6 error: %v", errA, errAAAA)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allAddrs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no A or AAAA records found for %s", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allAddrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
|
||||||
|
// It uses DNSSEC validation to ensure the chain of trust is valid.
|
||||||
|
// Returns true if DNSSEC is properly configured and validated, false otherwise.
|
||||||
|
func (r *StandardDNSResolver) IsDNSSECEnabled(ctx context.Context, domain string) (bool, error) {
|
||||||
|
// Ensure the domain ends with a dot for DNS queries
|
||||||
|
queryName := domain
|
||||||
|
if !strings.HasSuffix(queryName, ".") {
|
||||||
|
queryName = queryName + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query for DNSKEY records with DNSSEC validation
|
||||||
|
// If this succeeds, it means:
|
||||||
|
// 1. The domain has DNSKEY records (DNSSEC is configured)
|
||||||
|
// 2. The DNSSEC chain of trust is valid (validated by StrictNSQuery)
|
||||||
|
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeDNSKEY)
|
||||||
|
if err != nil {
|
||||||
|
// DNSSEC is not enabled or validation failed
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got any DNSKEY records
|
||||||
|
if len(rrs) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we actually have DNSKEY records (not just any RR type)
|
||||||
|
hasDNSKEY := false
|
||||||
|
for _, rr := range rrs {
|
||||||
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
|
hasDNSKEY = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasDNSKEY, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
pkg/analyzer/dns_resolver_test.go
Normal file
111
pkg/analyzer/dns_resolver_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsDNSSECEnabled(t *testing.T) {
|
||||||
|
resolver := NewStandardDNSResolver()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
expectDNSSEC bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ietf.org has DNSSEC",
|
||||||
|
domain: "ietf.org",
|
||||||
|
expectDNSSEC: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "google.com doesn't have DNSSEC",
|
||||||
|
domain: "google.com",
|
||||||
|
expectDNSSEC: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
enabled, err := resolver.IsDNSSECEnabled(ctx, tt.domain)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("IsDNSSECEnabled() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled != tt.expectDNSSEC {
|
||||||
|
t.Errorf("IsDNSSECEnabled() for %s = %v, want %v", tt.domain, enabled, tt.expectDNSSEC)
|
||||||
|
} else {
|
||||||
|
// Log the result even if we're not validating
|
||||||
|
if enabled {
|
||||||
|
t.Logf("%s: DNSSEC is enabled ✅", tt.domain)
|
||||||
|
} else {
|
||||||
|
t.Logf("%s: DNSSEC is NOT enabled ⚠️", tt.domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDNSSECEnabled_NonExistentDomain(t *testing.T) {
|
||||||
|
resolver := NewStandardDNSResolver()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with a domain that doesn't exist
|
||||||
|
enabled, err := resolver.IsDNSSECEnabled(ctx, "this-domain-definitely-does-not-exist-12345.com")
|
||||||
|
if err != nil {
|
||||||
|
// Error is acceptable for non-existent domains
|
||||||
|
t.Logf("Non-existent domain returned error (expected): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no error, DNSSEC should be disabled
|
||||||
|
if enabled {
|
||||||
|
t.Error("IsDNSSECEnabled() for non-existent domain should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDNSSECEnabled_WithTrailingDot(t *testing.T) {
|
||||||
|
resolver := NewStandardDNSResolver()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test that both formats work
|
||||||
|
domain1 := "cloudflare.com"
|
||||||
|
domain2 := "cloudflare.com."
|
||||||
|
|
||||||
|
enabled1, err1 := resolver.IsDNSSECEnabled(ctx, domain1)
|
||||||
|
if err1 != nil {
|
||||||
|
t.Errorf("IsDNSSECEnabled() without trailing dot error = %v", err1)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled2, err2 := resolver.IsDNSSECEnabled(ctx, domain2)
|
||||||
|
if err2 != nil {
|
||||||
|
t.Errorf("IsDNSSECEnabled() with trailing dot error = %v", err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled1 != enabled2 {
|
||||||
|
t.Errorf("IsDNSSECEnabled() results differ: without dot = %v, with dot = %v", enabled1, enabled2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,34 +27,33 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
|
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
|
||||||
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord {
|
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
|
||||||
visited := make(map[string]bool)
|
visited := make(map[string]bool)
|
||||||
return d.resolveSPFRecords(domain, visited, 0, true)
|
return d.resolveSPFRecords(domain, visited, 0, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSPFRecords recursively resolves SPF records including include: directives
|
// resolveSPFRecords recursively resolves SPF records including include: directives
|
||||||
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
||||||
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord {
|
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
|
||||||
const maxDepth = 10 // Prevent infinite recursion
|
const maxDepth = 10 // Prevent infinite recursion
|
||||||
|
|
||||||
if depth > maxDepth {
|
if depth > maxDepth {
|
||||||
return &[]model.SPFRecord{
|
return &[]api.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("Maximum SPF include depth exceeded"),
|
Error: api.PtrTo("Maximum SPF include depth exceeded"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent circular references
|
// Prevent circular references
|
||||||
if visited[domain] {
|
if visited[domain] {
|
||||||
return &[]model.SPFRecord{}
|
return &[]api.SPFRecord{}
|
||||||
}
|
}
|
||||||
visited[domain] = true
|
visited[domain] = true
|
||||||
|
|
||||||
|
|
@ -63,11 +62,11 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &[]model.SPFRecord{
|
return &[]api.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,23 +82,23 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
if spfCount == 0 {
|
if spfCount == 0 {
|
||||||
return &[]model.SPFRecord{
|
return &[]api.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No SPF record found"),
|
Error: api.PtrTo("No SPF record found"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []model.SPFRecord
|
var results []api.SPFRecord
|
||||||
|
|
||||||
if spfCount > 1 {
|
if spfCount > 1 {
|
||||||
results = append(results, model.SPFRecord{
|
results = append(results, api.SPFRecord{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Record: &spfRecord,
|
Record: &spfRecord,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
|
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
|
||||||
})
|
})
|
||||||
return &results
|
return &results
|
||||||
}
|
}
|
||||||
|
|
@ -108,28 +107,28 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
||||||
|
|
||||||
// Extract the "all" mechanism qualifier
|
// Extract the "all" mechanism qualifier
|
||||||
var allQualifier *model.SPFRecordAllQualifier
|
var allQualifier *api.SPFRecordAllQualifier
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
|
|
||||||
if validationErr != nil {
|
if validationErr != nil {
|
||||||
errMsg = utils.PtrTo(validationErr.Error())
|
errMsg = api.PtrTo(validationErr.Error())
|
||||||
} else {
|
} else {
|
||||||
// Extract qualifier from the "all" mechanism
|
// Extract qualifier from the "all" mechanism
|
||||||
if strings.HasSuffix(spfRecord, " -all") {
|
if strings.HasSuffix(spfRecord, " -all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
|
||||||
} else if strings.HasSuffix(spfRecord, " ~all") {
|
} else if strings.HasSuffix(spfRecord, " ~all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
|
||||||
} else if strings.HasSuffix(spfRecord, " +all") {
|
} else if strings.HasSuffix(spfRecord, " +all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
||||||
} else if strings.HasSuffix(spfRecord, " ?all") {
|
} else if strings.HasSuffix(spfRecord, " ?all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
|
||||||
} else if strings.HasSuffix(spfRecord, " all") {
|
} else if strings.HasSuffix(spfRecord, " all") {
|
||||||
// Implicit + qualifier (default)
|
// Implicit + qualifier (default)
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, model.SPFRecord{
|
results = append(results, api.SPFRecord{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Record: &spfRecord,
|
Record: &spfRecord,
|
||||||
Valid: validationErr == nil,
|
Valid: validationErr == nil,
|
||||||
|
|
@ -302,7 +301,7 @@ func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
|
||||||
return strings.HasSuffix(record, " -all")
|
return strings.HasSuffix(record, " -all")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) {
|
||||||
// SPF is essential for email authentication
|
// SPF is essential for email authentication
|
||||||
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
||||||
// Find the main SPF record by skipping redirects
|
// Find the main SPF record by skipping redirects
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,7 @@ import (
|
||||||
|
|
||||||
"golang.org/x/net/publicsuffix"
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HeaderAnalyzer analyzes email header quality and structure
|
// HeaderAnalyzer analyzes email header quality and structure
|
||||||
|
|
@ -44,7 +43,7 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateHeaderScore evaluates email structural quality from header analysis
|
// CalculateHeaderScore evaluates email structural quality from header analysis
|
||||||
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
|
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
|
||||||
if analysis == nil || analysis.Headers == nil {
|
if analysis == nil || analysis.Headers == nil {
|
||||||
return 0, ' '
|
return 0, ' '
|
||||||
}
|
}
|
||||||
|
|
@ -110,13 +109,6 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (i
|
||||||
maxGrade -= 1
|
maxGrade -= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check MIME-Version header (-5 points if present but not "1.0")
|
|
||||||
if check, exists := headers["mime-version"]; exists && check.Present {
|
|
||||||
if check.Valid != nil && !*check.Valid {
|
|
||||||
score -= 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Message-ID format (10 points)
|
// Check Message-ID format (10 points)
|
||||||
if check, exists := headers["message-id"]; exists && check.Present {
|
if check, exists := headers["message-id"]; exists && check.Present {
|
||||||
// If Valid is set and true, award points
|
// If Valid is set and true, award points
|
||||||
|
|
@ -188,7 +180,7 @@ func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNoReplyAddress checks if a header check represents a no-reply email address
|
// isNoReplyAddress checks if a header check represents a no-reply email address
|
||||||
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
|
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
|
||||||
if !headerCheck.Present || headerCheck.Value == nil {
|
if !headerCheck.Present || headerCheck.Value == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -244,18 +236,18 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateHeaderAnalysis creates structured header analysis from email
|
// GenerateHeaderAnalysis creates structured header analysis from email
|
||||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
|
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
|
||||||
if email == nil {
|
if email == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis := &model.HeaderAnalysis{}
|
analysis := &api.HeaderAnalysis{}
|
||||||
|
|
||||||
// Check for proper MIME structure
|
// Check for proper MIME structure
|
||||||
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
|
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
|
||||||
|
|
||||||
// Initialize headers map
|
// Initialize headers map
|
||||||
headers := make(map[string]model.HeaderCheck)
|
headers := make(map[string]api.HeaderCheck)
|
||||||
|
|
||||||
// Check required headers
|
// Check required headers
|
||||||
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
||||||
|
|
@ -274,10 +266,6 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
||||||
headers[strings.ToLower(headerName)] = *check
|
headers[strings.ToLower(headerName)] = *check
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check MIME-Version header (recommended but absence is not penalized)
|
|
||||||
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
|
|
||||||
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
|
|
||||||
|
|
||||||
// Check optional headers
|
// Check optional headers
|
||||||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
||||||
for _, headerName := range optionalHeaders {
|
for _, headerName := range optionalHeaders {
|
||||||
|
|
@ -309,12 +297,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHeader checks if a header is present and valid
|
// checkHeader checks if a header is present and valid
|
||||||
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
|
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
|
||||||
value := email.GetHeaderValue(headerName)
|
value := email.GetHeaderValue(headerName)
|
||||||
present := email.HasHeader(headerName) && value != ""
|
present := email.HasHeader(headerName) && value != ""
|
||||||
|
|
||||||
importanceEnum := model.HeaderCheckImportance(importance)
|
importanceEnum := api.HeaderCheckImportance(importance)
|
||||||
check := &model.HeaderCheck{
|
check := &api.HeaderCheck{
|
||||||
Present: present,
|
Present: present,
|
||||||
Importance: &importanceEnum,
|
Importance: &importanceEnum,
|
||||||
}
|
}
|
||||||
|
|
@ -332,21 +320,12 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
||||||
valid = false
|
valid = false
|
||||||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||||||
}
|
}
|
||||||
if len(email.Header["Message-Id"]) > 1 {
|
|
||||||
valid = false
|
|
||||||
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
|
|
||||||
}
|
|
||||||
case "Date":
|
case "Date":
|
||||||
// Validate date format
|
// Validate date format
|
||||||
if _, err := h.parseEmailDate(value); err != nil {
|
if _, err := h.parseEmailDate(value); err != nil {
|
||||||
valid = false
|
valid = false
|
||||||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
||||||
}
|
}
|
||||||
case "MIME-Version":
|
|
||||||
if value != "1.0" {
|
|
||||||
valid = false
|
|
||||||
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
|
|
||||||
}
|
|
||||||
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
||||||
// Parse address header using net/mail and get normalized address
|
// Parse address header using net/mail and get normalized address
|
||||||
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
||||||
|
|
@ -375,10 +354,10 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
||||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
|
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
|
||||||
alignment := &model.DomainAlignment{
|
alignment := &api.DomainAlignment{
|
||||||
Aligned: utils.PtrTo(true),
|
Aligned: api.PtrTo(true),
|
||||||
RelaxedAligned: utils.PtrTo(true),
|
RelaxedAligned: api.PtrTo(true),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract From domain
|
// Extract From domain
|
||||||
|
|
@ -406,13 +385,13 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract DKIM domains from authentication results
|
// Extract DKIM domains from authentication results
|
||||||
var dkimDomains []model.DKIMDomainInfo
|
var dkimDomains []api.DKIMDomainInfo
|
||||||
if authResults != nil && authResults.Dkim != nil {
|
if authResults != nil && authResults.Dkim != nil {
|
||||||
for _, dkim := range *authResults.Dkim {
|
for _, dkim := range *authResults.Dkim {
|
||||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||||
domain := *dkim.Domain
|
domain := *dkim.Domain
|
||||||
orgDomain := h.getOrganizationalDomain(domain)
|
orgDomain := h.getOrganizationalDomain(domain)
|
||||||
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
OrgDomain: orgDomain,
|
OrgDomain: orgDomain,
|
||||||
})
|
})
|
||||||
|
|
@ -561,18 +540,18 @@ func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// findHeaderIssues identifies issues with headers
|
// findHeaderIssues identifies issues with headers
|
||||||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
|
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
|
||||||
var issues []model.HeaderIssue
|
var issues []api.HeaderIssue
|
||||||
|
|
||||||
// Check for missing required headers
|
// Check for missing required headers
|
||||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||||
for _, header := range requiredHeaders {
|
for _, header := range requiredHeaders {
|
||||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||||
issues = append(issues, model.HeaderIssue{
|
issues = append(issues, api.HeaderIssue{
|
||||||
Header: header,
|
Header: header,
|
||||||
Severity: model.HeaderIssueSeverityCritical,
|
Severity: api.HeaderIssueSeverityCritical,
|
||||||
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
||||||
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -580,11 +559,11 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss
|
||||||
// Check Message-ID format
|
// Check Message-ID format
|
||||||
messageID := email.GetHeaderValue("Message-ID")
|
messageID := email.GetHeaderValue("Message-ID")
|
||||||
if messageID != "" && !h.isValidMessageID(messageID) {
|
if messageID != "" && !h.isValidMessageID(messageID) {
|
||||||
issues = append(issues, model.HeaderIssue{
|
issues = append(issues, api.HeaderIssue{
|
||||||
Header: "Message-ID",
|
Header: "Message-ID",
|
||||||
Severity: model.HeaderIssueSeverityMedium,
|
Severity: api.HeaderIssueSeverityMedium,
|
||||||
Message: "Message-ID format is invalid",
|
Message: "Message-ID format is invalid",
|
||||||
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,7 +571,7 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedChain extracts the chain of Received headers from an email
|
// parseReceivedChain extracts the chain of Received headers from an email
|
||||||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
|
||||||
if email == nil || email.Header == nil {
|
if email == nil || email.Header == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -602,7 +581,7 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.Receive
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var chain []model.ReceivedHop
|
var chain []api.ReceivedHop
|
||||||
|
|
||||||
for _, receivedValue := range receivedHeaders {
|
for _, receivedValue := range receivedHeaders {
|
||||||
hop := h.parseReceivedHeader(receivedValue)
|
hop := h.parseReceivedHeader(receivedValue)
|
||||||
|
|
@ -615,8 +594,8 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.Receive
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedHeader parses a single Received header value
|
// parseReceivedHeader parses a single Received header value
|
||||||
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
|
||||||
hop := &model.ReceivedHop{}
|
hop := &api.ReceivedHop{}
|
||||||
|
|
||||||
// Normalize whitespace - Received headers can span multiple lines
|
// Normalize whitespace - Received headers can span multiple lines
|
||||||
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCalculateHeaderScore(t *testing.T) {
|
func TestCalculateHeaderScore(t *testing.T) {
|
||||||
|
|
@ -404,7 +404,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
name string
|
name string
|
||||||
receivedHeaders []string
|
receivedHeaders []string
|
||||||
expectedHops int
|
expectedHops int
|
||||||
validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop)
|
validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "No Received headers",
|
name: "No Received headers",
|
||||||
|
|
@ -417,7 +417,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +450,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
||||||
},
|
},
|
||||||
expectedHops: 2,
|
expectedHops: 2,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) != 2 {
|
if len(hops) != 2 {
|
||||||
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
||||||
}
|
}
|
||||||
|
|
@ -472,7 +472,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -499,7 +499,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +527,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from unknown by localhost",
|
"from unknown by localhost",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -1012,16 +1012,16 @@ func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create authentication results with DKIM signatures
|
// Create authentication results with DKIM signatures
|
||||||
var authResults *model.AuthenticationResults
|
var authResults *api.AuthenticationResults
|
||||||
if len(tt.dkimDomains) > 0 {
|
if len(tt.dkimDomains) > 0 {
|
||||||
dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
|
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
|
||||||
for _, domain := range tt.dkimDomains {
|
for _, domain := range tt.dkimDomains {
|
||||||
dkimResults = append(dkimResults, model.AuthResult{
|
dkimResults = append(dkimResults, api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
authResults = &model.AuthenticationResults{
|
authResults = &api.AuthenticationResults{
|
||||||
Dkim: &dkimResults,
|
Dkim: &dkimResults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,16 @@ import (
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var hostname = ""
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
hostname, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
// EmailMessage represents a parsed email message
|
// EmailMessage represents a parsed email message
|
||||||
type EmailMessage struct {
|
type EmailMessage struct {
|
||||||
Header mail.Header
|
Header mail.Header
|
||||||
|
|
@ -211,18 +218,18 @@ func buildRawHeaders(header mail.Header) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthenticationResults extracts Authentication-Results headers
|
// GetAuthenticationResults extracts Authentication-Results headers
|
||||||
// If receiverHostname is provided, only returns headers that begin with that hostname
|
// If hostname is provided, only returns headers that begin with that hostname
|
||||||
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
|
func (e *EmailMessage) GetAuthenticationResults() []string {
|
||||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||||
|
|
||||||
// If no hostname specified, return all results
|
// If no hostname specified, return all results
|
||||||
if receiverHostname == "" {
|
if hostname == "" {
|
||||||
return allResults
|
return allResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter results that begin with the specified hostname
|
// Filter results that begin with the specified hostname
|
||||||
var filtered []string
|
var filtered []string
|
||||||
prefix := receiverHostname + ";"
|
prefix := hostname + ";"
|
||||||
for _, result := range allResults {
|
for _, result := range allResults {
|
||||||
// Trim whitespace and check if it starts with hostname;
|
// Trim whitespace and check if it starts with hostname;
|
||||||
trimmed := strings.TrimSpace(result)
|
trimmed := strings.TrimSpace(result)
|
||||||
|
|
@ -249,33 +256,6 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, headerName := range saHeaders {
|
for _, headerName := range saHeaders {
|
||||||
if values, ok := e.Header[headerName]; ok && len(values) > 0 {
|
|
||||||
for _, value := range values {
|
|
||||||
if strings.TrimSpace(value) != "" {
|
|
||||||
headers[headerName] = value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if value := e.Header.Get(headerName); value != "" {
|
|
||||||
headers[headerName] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRspamdHeaders extracts rspamd-related headers
|
|
||||||
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
|
|
||||||
headers := make(map[string]string)
|
|
||||||
|
|
||||||
rspamdHeaders := []string{
|
|
||||||
"X-Spamd-Result",
|
|
||||||
"X-Rspamd-Score",
|
|
||||||
"X-Rspamd-Action",
|
|
||||||
"X-Rspamd-Server",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, headerName := range rspamdHeaders {
|
|
||||||
if value := e.Header.Get(headerName); value != "" {
|
if value := e.Header.Get(headerName); value != "" {
|
||||||
headers[headerName] = value
|
headers[headerName] = value
|
||||||
}
|
}
|
||||||
|
|
@ -321,20 +301,3 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
|
||||||
func (e *EmailMessage) HasHeader(key string) bool {
|
func (e *EmailMessage) HasHeader(key string) bool {
|
||||||
return e.Header.Get(key) != ""
|
return e.Header.Get(key) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
|
|
||||||
// The header format is: <url1>, <url2>, ...
|
|
||||||
func (e *EmailMessage) GetListUnsubscribeURLs() []string {
|
|
||||||
value := e.Header.Get("List-Unsubscribe")
|
|
||||||
if value == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var urls []string
|
|
||||||
for _, part := range strings.Split(value, ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") {
|
|
||||||
urls = append(urls, part[1:len(part)-1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,9 @@ Content-Type: text/html; charset=utf-8
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAuthenticationResults(t *testing.T) {
|
func TestGetAuthenticationResults(t *testing.T) {
|
||||||
|
// Force hostname
|
||||||
|
hostname = "example.com"
|
||||||
|
|
||||||
rawEmail := `From: sender@example.com
|
rawEmail := `From: sender@example.com
|
||||||
To: recipient@example.com
|
To: recipient@example.com
|
||||||
Subject: Test Email
|
Subject: Test Email
|
||||||
|
|
@ -120,7 +123,7 @@ Body content.
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authResults := email.GetAuthenticationResults("example.com")
|
authResults := email.GetAuthenticationResults()
|
||||||
if len(authResults) != 2 {
|
if len(authResults) != 2 {
|
||||||
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,22 +27,17 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
// RBLChecker checks IP addresses against DNS-based blacklists
|
||||||
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
|
type RBLChecker struct {
|
||||||
type DNSListChecker struct {
|
Timeout time.Duration
|
||||||
Timeout time.Duration
|
RBLs []string
|
||||||
Lists []string
|
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
resolver *net.Resolver
|
||||||
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
|
|
||||||
resolver *net.Resolver
|
|
||||||
informationalSet map[string]bool // Lists whose hits don't count toward the score
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRBLs is a list of commonly used RBL providers
|
// DefaultRBLs is a list of commonly used RBL providers
|
||||||
|
|
@ -53,83 +48,40 @@ var DefaultRBLs = []string{
|
||||||
"b.barracudacentral.org", // Barracuda
|
"b.barracudacentral.org", // Barracuda
|
||||||
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
|
||||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
|
||||||
"psbl.surriel.com", // PSBL
|
|
||||||
"dnsbl.dronebl.org", // DroneBL
|
|
||||||
"bl.mailspike.net", // Mailspike BL
|
|
||||||
"z.mailspike.net", // Mailspike Z
|
|
||||||
"bl.rbl-dns.com", // RBL-DNS
|
|
||||||
"bl.nszones.com", // NSZones
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
|
|
||||||
// These are typically broader lists where being listed is less definitive.
|
|
||||||
var DefaultInformationalRBLs = []string{
|
|
||||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
|
|
||||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultDNSWLs is a list of commonly used DNSWL providers
|
|
||||||
var DefaultDNSWLs = []string{
|
|
||||||
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
|
|
||||||
"swl.spamhaus.org", // Spamhaus Safe Whitelist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
|
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker {
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 5 * time.Second
|
timeout = 5 * time.Second // Default timeout
|
||||||
}
|
}
|
||||||
if len(rbls) == 0 {
|
if len(rbls) == 0 {
|
||||||
rbls = DefaultRBLs
|
rbls = DefaultRBLs
|
||||||
}
|
}
|
||||||
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
|
return &RBLChecker{
|
||||||
for _, rbl := range DefaultInformationalRBLs {
|
Timeout: timeout,
|
||||||
informationalSet[rbl] = true
|
RBLs: rbls,
|
||||||
}
|
CheckAllIPs: checkAllIPs,
|
||||||
return &DNSListChecker{
|
resolver: &net.Resolver{
|
||||||
Timeout: timeout,
|
PreferGo: true,
|
||||||
Lists: rbls,
|
},
|
||||||
CheckAllIPs: checkAllIPs,
|
|
||||||
filterErrorCodes: true,
|
|
||||||
resolver: &net.Resolver{PreferGo: true},
|
|
||||||
informationalSet: informationalSet,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
|
// RBLResults represents the results of RBL checks
|
||||||
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
|
type RBLResults struct {
|
||||||
if timeout == 0 {
|
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
|
||||||
timeout = 5 * time.Second
|
IPsChecked []string
|
||||||
}
|
ListedCount int
|
||||||
if len(dnswls) == 0 {
|
|
||||||
dnswls = DefaultDNSWLs
|
|
||||||
}
|
|
||||||
return &DNSListChecker{
|
|
||||||
Timeout: timeout,
|
|
||||||
Lists: dnswls,
|
|
||||||
CheckAllIPs: checkAllIPs,
|
|
||||||
filterErrorCodes: false,
|
|
||||||
resolver: &net.Resolver{PreferGo: true},
|
|
||||||
informationalSet: make(map[string]bool),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSListResults represents the results of DNS list checks
|
// CheckEmail checks all IPs found in the email headers against RBLs
|
||||||
type DNSListResults struct {
|
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||||
Checks map[string][]model.BlacklistCheck // Map of IP -> list of checks for that IP
|
results := &RBLResults{
|
||||||
IPsChecked []string
|
Checks: make(map[string][]api.BlacklistCheck),
|
||||||
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][]model.BlacklistCheck),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract IPs from Received headers
|
||||||
ips := r.extractIPs(email)
|
ips := r.extractIPs(email)
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return results
|
return results
|
||||||
|
|
@ -137,18 +89,17 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
|
|
||||||
results.IPsChecked = ips
|
results.IPsChecked = ips
|
||||||
|
|
||||||
|
// Check each IP against all RBLs
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
for _, list := range r.Lists {
|
for _, rbl := range r.RBLs {
|
||||||
check := r.checkIP(ip, list)
|
check := r.checkIP(ip, rbl)
|
||||||
results.Checks[ip] = append(results.Checks[ip], check)
|
results.Checks[ip] = append(results.Checks[ip], check)
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
results.ListedCount++
|
results.ListedCount++
|
||||||
if !r.informationalSet[list] {
|
|
||||||
results.RelevantListedCount++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only check the first IP unless CheckAllIPs is enabled
|
||||||
if !r.CheckAllIPs {
|
if !r.CheckAllIPs {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -157,26 +108,20 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckIP checks a single IP address against all configured lists in parallel
|
// CheckIP checks a single IP address against all configured RBLs
|
||||||
func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) {
|
func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||||
|
// Validate that it's a valid IP address
|
||||||
if !r.isPublicIP(ip) {
|
if !r.isPublicIP(ip) {
|
||||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := make([]model.BlacklistCheck, len(r.Lists))
|
var checks []api.BlacklistCheck
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for i, list := range r.Lists {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int, list string) {
|
|
||||||
defer wg.Done()
|
|
||||||
checks[i] = r.checkIP(ip, list)
|
|
||||||
}(i, list)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
listedCount := 0
|
listedCount := 0
|
||||||
for _, check := range checks {
|
|
||||||
|
// Check the IP against all RBLs
|
||||||
|
for _, rbl := range r.RBLs {
|
||||||
|
check := r.checkIP(ip, rbl)
|
||||||
|
checks = append(checks, check)
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
listedCount++
|
listedCount++
|
||||||
}
|
}
|
||||||
|
|
@ -186,19 +131,27 @@ func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractIPs extracts IP addresses from Received headers
|
// extractIPs extracts IP addresses from Received headers
|
||||||
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
seenIPs := make(map[string]bool)
|
seenIPs := make(map[string]bool)
|
||||||
|
|
||||||
|
// Get all Received headers
|
||||||
receivedHeaders := email.Header["Received"]
|
receivedHeaders := email.Header["Received"]
|
||||||
|
|
||||||
|
// Regex patterns for IP addresses
|
||||||
|
// Match IPv4: xxx.xxx.xxx.xxx
|
||||||
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
|
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
|
||||||
|
|
||||||
|
// Look for IPs in Received headers
|
||||||
for _, received := range receivedHeaders {
|
for _, received := range receivedHeaders {
|
||||||
|
// Find all IPv4 addresses
|
||||||
matches := ipv4Pattern.FindAllString(received, -1)
|
matches := ipv4Pattern.FindAllString(received, -1)
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
|
// Skip private/reserved IPs
|
||||||
if !r.isPublicIP(match) {
|
if !r.isPublicIP(match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Avoid duplicates
|
||||||
if !seenIPs[match] {
|
if !seenIPs[match] {
|
||||||
ips = append(ips, match)
|
ips = append(ips, match)
|
||||||
seenIPs[match] = true
|
seenIPs[match] = true
|
||||||
|
|
@ -206,10 +159,13 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no IPs found in Received headers, try X-Originating-IP
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
originatingIP := email.Header.Get("X-Originating-IP")
|
originatingIP := email.Header.Get("X-Originating-IP")
|
||||||
if originatingIP != "" {
|
if originatingIP != "" {
|
||||||
|
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
|
||||||
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||||
|
// Remove any whitespace
|
||||||
cleanIP = strings.TrimSpace(cleanIP)
|
cleanIP = strings.TrimSpace(cleanIP)
|
||||||
matches := ipv4Pattern.FindString(cleanIP)
|
matches := ipv4Pattern.FindString(cleanIP)
|
||||||
if matches != "" && r.isPublicIP(matches) {
|
if matches != "" && r.isPublicIP(matches) {
|
||||||
|
|
@ -222,16 +178,19 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||||
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
||||||
ip := net.ParseIP(ipStr)
|
ip := net.ParseIP(ipStr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a private network
|
||||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional checks for reserved ranges
|
||||||
|
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
|
||||||
if ip.IsUnspecified() {
|
if ip.IsUnspecified() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -239,43 +198,51 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkIP checks a single IP against a single DNS list
|
// checkIP checks a single IP against a single RBL
|
||||||
func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
|
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
||||||
check := model.BlacklistCheck{
|
check := api.BlacklistCheck{
|
||||||
Rbl: list,
|
Rbl: rbl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse the IP for DNSBL query
|
||||||
reversedIP := r.reverseIP(ip)
|
reversedIP := r.reverseIP(ip)
|
||||||
if reversedIP == "" {
|
if reversedIP == "" {
|
||||||
check.Error = utils.PtrTo("Failed to reverse IP address")
|
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf("%s.%s", reversedIP, list)
|
// Construct DNSBL query: reversed-ip.rbl-domain
|
||||||
|
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
|
||||||
|
|
||||||
|
// Perform DNS lookup with timeout
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
addrs, err := r.resolver.LookupHost(ctx, query)
|
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Most likely not listed (NXDOMAIN)
|
||||||
if dnsErr, ok := err.(*net.DNSError); ok {
|
if dnsErr, ok := err.(*net.DNSError); ok {
|
||||||
if dnsErr.IsNotFound {
|
if dnsErr.IsNotFound {
|
||||||
check.Listed = false
|
check.Listed = false
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
// Other DNS errors
|
||||||
|
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we got a response, check the return code
|
||||||
if len(addrs) > 0 {
|
if len(addrs) > 0 {
|
||||||
check.Response = utils.PtrTo(addrs[0])
|
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
|
||||||
|
|
||||||
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
|
||||||
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
// These indicate RBL operational issues, not actual listings
|
||||||
|
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
|
||||||
check.Listed = false
|
check.Listed = false
|
||||||
check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]))
|
||||||
} else {
|
} else {
|
||||||
|
// Normal listing response
|
||||||
check.Listed = true
|
check.Listed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -283,58 +250,44 @@ func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
|
// reverseIP reverses an IPv4 address for DNSBL queries
|
||||||
// Example: 192.0.2.1 -> 1.2.0.192
|
// Example: 192.0.2.1 -> 1.2.0.192
|
||||||
func (r *DNSListChecker) reverseIP(ipStr string) string {
|
func (r *RBLChecker) reverseIP(ipStr string) string {
|
||||||
ip := net.ParseIP(ipStr)
|
ip := net.ParseIP(ipStr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert to IPv4
|
||||||
ipv4 := ip.To4()
|
ipv4 := ip.To4()
|
||||||
if ipv4 == nil {
|
if ipv4 == nil {
|
||||||
return "" // IPv6 not supported yet
|
return "" // IPv6 not supported yet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse the octets
|
||||||
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateScore calculates the list contribution to deliverability.
|
// CalculateRBLScore calculates the blacklist contribution to deliverability
|
||||||
// Informational lists are not counted in the score.
|
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) {
|
||||||
func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
|
|
||||||
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
|
||||||
|
|
||||||
if forWhitelist {
|
|
||||||
if results.ListedCount >= scoringListCount {
|
|
||||||
return 100, "A++"
|
|
||||||
} else if results.ListedCount > 0 {
|
|
||||||
return 100, "A+"
|
|
||||||
} else {
|
|
||||||
return 95, "A"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if results == nil || len(results.IPsChecked) == 0 {
|
if results == nil || len(results.IPsChecked) == 0 {
|
||||||
|
// No IPs to check, give benefit of doubt
|
||||||
return 100, ""
|
return 100, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if results.ListedCount <= 0 {
|
percentage := 100 - results.ListedCount*100/len(r.RBLs)
|
||||||
return 100, "A+"
|
|
||||||
}
|
|
||||||
|
|
||||||
percentage := 100 - results.RelevantListedCount*100/scoringListCount
|
|
||||||
return percentage, ScoreToGrade(percentage)
|
return percentage, ScoreToGrade(percentage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
|
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
||||||
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||||
var listedIPs []string
|
var listedIPs []string
|
||||||
|
|
||||||
for ip, checks := range results.Checks {
|
for ip, rblChecks := range results.Checks {
|
||||||
for _, check := range checks {
|
for _, check := range rblChecks {
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
listedIPs = append(listedIPs, ip)
|
listedIPs = append(listedIPs, ip)
|
||||||
break
|
break // Only add the IP once
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -342,17 +295,17 @@ func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
||||||
return listedIPs
|
return listedIPs
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetListsForIP returns all lists that match a specific IP
|
// GetRBLsForIP returns all RBLs that list a specific IP
|
||||||
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
|
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
||||||
var lists []string
|
var rbls []string
|
||||||
|
|
||||||
if checks, exists := results.Checks[ip]; exists {
|
if rblChecks, exists := results.Checks[ip]; exists {
|
||||||
for _, check := range checks {
|
for _, check := range rblChecks {
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
lists = append(lists, check.Rbl)
|
rbls = append(rbls, check.Rbl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lists
|
return rbls
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRBLChecker(t *testing.T) {
|
func TestNewRBLChecker(t *testing.T) {
|
||||||
|
|
@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) {
|
||||||
if checker.Timeout != tt.expectedTimeout {
|
if checker.Timeout != tt.expectedTimeout {
|
||||||
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||||
}
|
}
|
||||||
if len(checker.Lists) != tt.expectedRBLs {
|
if len(checker.RBLs) != tt.expectedRBLs {
|
||||||
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
|
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
||||||
}
|
}
|
||||||
if checker.resolver == nil {
|
if checker.resolver == nil {
|
||||||
t.Error("Resolver should not be nil")
|
t.Error("Resolver should not be nil")
|
||||||
|
|
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
|
||||||
func TestGetBlacklistScore(t *testing.T) {
|
func TestGetBlacklistScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
results *DNSListResults
|
results *RBLResults
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No IPs checked",
|
name: "No IPs checked",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{},
|
IPsChecked: []string{},
|
||||||
},
|
},
|
||||||
expectedScore: 100,
|
expectedScore: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Not listed on any RBL",
|
name: "Not listed on any RBL",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 0,
|
ListedCount: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 1 RBL",
|
name: "Listed on 1 RBL",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 1,
|
ListedCount: 1,
|
||||||
},
|
},
|
||||||
|
|
@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 2 RBLs",
|
name: "Listed on 2 RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 2,
|
ListedCount: 2,
|
||||||
},
|
},
|
||||||
|
|
@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 3 RBLs",
|
name: "Listed on 3 RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 3,
|
ListedCount: 3,
|
||||||
},
|
},
|
||||||
|
|
@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 4+ RBLs",
|
name: "Listed on 4+ RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 4,
|
ListedCount: 4,
|
||||||
},
|
},
|
||||||
|
|
@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
score, _ := checker.CalculateScore(tt.results)
|
score, _ := checker.CalculateRBLScore(tt.results)
|
||||||
if score != tt.expectedScore {
|
if score != tt.expectedScore {
|
||||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||||
}
|
}
|
||||||
|
|
@ -335,8 +335,8 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUniqueListedIPs(t *testing.T) {
|
func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]model.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
{Rbl: "bl.spamcop.net", Listed: true},
|
{Rbl: "bl.spamcop.net", Listed: true},
|
||||||
|
|
@ -363,8 +363,8 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRBLsForIP(t *testing.T) {
|
func TestGetRBLsForIP(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]model.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
{Rbl: "bl.spamcop.net", Listed: true},
|
{Rbl: "bl.spamcop.net", Listed: true},
|
||||||
|
|
@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
rbls := checker.GetListsForIP(results, tt.ip)
|
rbls := checker.GetRBLsForIP(results, tt.ip)
|
||||||
|
|
||||||
if len(rbls) != len(tt.expectedRBLs) {
|
if len(rbls) != len(tt.expectedRBLs) {
|
||||||
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
@ -33,31 +33,24 @@ import (
|
||||||
type ReportGenerator struct {
|
type ReportGenerator struct {
|
||||||
authAnalyzer *AuthenticationAnalyzer
|
authAnalyzer *AuthenticationAnalyzer
|
||||||
spamAnalyzer *SpamAssassinAnalyzer
|
spamAnalyzer *SpamAssassinAnalyzer
|
||||||
rspamdAnalyzer *RspamdAnalyzer
|
|
||||||
dnsAnalyzer *DNSAnalyzer
|
dnsAnalyzer *DNSAnalyzer
|
||||||
rblChecker *DNSListChecker
|
rblChecker *RBLChecker
|
||||||
dnswlChecker *DNSListChecker
|
|
||||||
contentAnalyzer *ContentAnalyzer
|
contentAnalyzer *ContentAnalyzer
|
||||||
headerAnalyzer *HeaderAnalyzer
|
headerAnalyzer *HeaderAnalyzer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReportGenerator creates a new report generator
|
// NewReportGenerator creates a new report generator
|
||||||
func NewReportGenerator(
|
func NewReportGenerator(
|
||||||
receiverHostname string,
|
|
||||||
dnsTimeout time.Duration,
|
dnsTimeout time.Duration,
|
||||||
httpTimeout time.Duration,
|
httpTimeout time.Duration,
|
||||||
rbls []string,
|
rbls []string,
|
||||||
dnswls []string,
|
|
||||||
checkAllIPs bool,
|
checkAllIPs bool,
|
||||||
rspamdAPIURL string,
|
|
||||||
) *ReportGenerator {
|
) *ReportGenerator {
|
||||||
return &ReportGenerator{
|
return &ReportGenerator{
|
||||||
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||||
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
|
|
||||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
|
||||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||||
headerAnalyzer: NewHeaderAnalyzer(),
|
headerAnalyzer: NewHeaderAnalyzer(),
|
||||||
}
|
}
|
||||||
|
|
@ -66,14 +59,12 @@ func NewReportGenerator(
|
||||||
// AnalysisResults contains all intermediate analysis results
|
// AnalysisResults contains all intermediate analysis results
|
||||||
type AnalysisResults struct {
|
type AnalysisResults struct {
|
||||||
Email *EmailMessage
|
Email *EmailMessage
|
||||||
Authentication *model.AuthenticationResults
|
Authentication *api.AuthenticationResults
|
||||||
Content *ContentResults
|
Content *ContentResults
|
||||||
DNS *model.DNSResults
|
DNS *api.DNSResults
|
||||||
Headers *model.HeaderAnalysis
|
Headers *api.HeaderAnalysis
|
||||||
RBL *DNSListResults
|
RBL *RBLResults
|
||||||
DNSWL *DNSListResults
|
SpamAssassin *api.SpamAssassinResult
|
||||||
SpamAssassin *model.SpamAssassinResult
|
|
||||||
Rspamd *model.RspamdResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeEmail performs complete email analysis
|
// AnalyzeEmail performs complete email analysis
|
||||||
|
|
@ -85,22 +76,20 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
|
||||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||||
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
|
|
||||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateReport creates a complete API report from analysis results
|
// GenerateReport creates a complete API report from analysis results
|
||||||
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
|
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
|
||||||
reportID := uuid.New()
|
reportID := uuid.New()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
report := &model.Report{
|
report := &api.Report{
|
||||||
Id: utils.UUIDToBase32(reportID),
|
Id: utils.UUIDToBase32(reportID),
|
||||||
TestId: utils.UUIDToBase32(testID),
|
TestId: utils.UUIDToBase32(testID),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
|
@ -141,47 +130,29 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
|
|
||||||
blacklistScore := 0
|
blacklistScore := 0
|
||||||
var blacklistGrade string
|
var blacklistGrade string
|
||||||
var whitelistGrade string
|
|
||||||
if results.RBL != nil {
|
if results.RBL != nil {
|
||||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
|
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
|
||||||
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
spamScore := 0
|
||||||
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
|
|
||||||
|
|
||||||
// Combine SpamAssassin and rspamd scores 50/50.
|
|
||||||
// If only one filter ran (the other returns "" grade), use that filter's score alone.
|
|
||||||
var spamScore int
|
|
||||||
var spamGrade string
|
var spamGrade string
|
||||||
switch {
|
if results.SpamAssassin != nil {
|
||||||
case saGrade == "" && rspamdGrade == "":
|
spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||||
spamScore = 0
|
|
||||||
spamGrade = ""
|
|
||||||
case saGrade == "":
|
|
||||||
spamScore = rspamdScore
|
|
||||||
spamGrade = rspamdGrade
|
|
||||||
case rspamdGrade == "":
|
|
||||||
spamScore = saScore
|
|
||||||
spamGrade = saGrade
|
|
||||||
default:
|
|
||||||
spamScore = (saScore + rspamdScore) / 2
|
|
||||||
spamGrade = MinGrade(saGrade, rspamdGrade)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
report.Summary = &model.ScoreSummary{
|
report.Summary = &api.ScoreSummary{
|
||||||
DnsScore: dnsScore,
|
DnsScore: dnsScore,
|
||||||
DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
|
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
|
||||||
AuthenticationScore: authScore,
|
AuthenticationScore: authScore,
|
||||||
AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
|
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
||||||
BlacklistScore: blacklistScore,
|
BlacklistScore: blacklistScore,
|
||||||
BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
|
||||||
ContentScore: contentScore,
|
ContentScore: contentScore,
|
||||||
ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
|
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
||||||
HeaderScore: headerScore,
|
HeaderScore: headerScore,
|
||||||
HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
|
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
|
||||||
SpamScore: spamScore,
|
SpamScore: spamScore,
|
||||||
SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
|
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add authentication results
|
// Add authentication results
|
||||||
|
|
@ -206,27 +177,9 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
report.Blacklists = &results.RBL.Checks
|
report.Blacklists = &results.RBL.Checks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
|
// Add SpamAssassin result
|
||||||
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
|
|
||||||
report.Whitelists = &results.DNSWL.Checks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add SpamAssassin result with individual deliverability score
|
|
||||||
if results.SpamAssassin != nil {
|
|
||||||
saGradeTyped := model.SpamAssassinResultDeliverabilityGrade(saGrade)
|
|
||||||
results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
|
|
||||||
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
|
||||||
}
|
|
||||||
report.Spamassassin = results.SpamAssassin
|
report.Spamassassin = results.SpamAssassin
|
||||||
|
|
||||||
// Add rspamd result with individual deliverability score
|
|
||||||
if results.Rspamd != nil {
|
|
||||||
rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
|
|
||||||
results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
|
|
||||||
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
|
||||||
}
|
|
||||||
report.Rspamd = results.Rspamd
|
|
||||||
|
|
||||||
// Add raw headers
|
// Add raw headers
|
||||||
if results.Email != nil && results.Email.RawHeaders != "" {
|
if results.Email != nil && results.Email.RawHeaders != "" {
|
||||||
report.RawHeaders = &results.Email.RawHeaders
|
report.RawHeaders = &results.Email.RawHeaders
|
||||||
|
|
@ -288,7 +241,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
}
|
}
|
||||||
|
|
||||||
if minusGrade < 255 {
|
if minusGrade < 255 {
|
||||||
report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade}))
|
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewReportGenerator(t *testing.T) {
|
func TestNewReportGenerator(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||||
if gen == nil {
|
if gen == nil {
|
||||||
t.Fatal("Expected report generator, got nil")
|
t.Fatal("Expected report generator, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyzeEmail(t *testing.T) {
|
func TestAnalyzeEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReport(t *testing.T) {
|
func TestGenerateReport(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmailWithSpamAssassin()
|
email := createTestEmailWithSpamAssassin()
|
||||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRawEmail(t *testing.T) {
|
func TestGenerateRawEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# rspamd-symbols.json
|
|
||||||
|
|
||||||
This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
|
|
||||||
|
|
||||||
## How to update
|
|
||||||
|
|
||||||
Fetch the latest symbols from a running rspamd instance:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with docker:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run --rm --name rspamd --pull always rspamd/rspamd
|
|
||||||
docker exec -u 0 rspamd apt install -y curl
|
|
||||||
docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Then rebuild the project.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,174 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
symbols map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
|
|
||||||
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
|
|
||||||
return &RspamdAnalyzer{symbols: symbols}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
|
||||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
|
|
||||||
headers := email.GetRspamdHeaders()
|
|
||||||
if len(headers) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
|
|
||||||
_, hasSpamdResult := headers["X-Spamd-Result"]
|
|
||||||
_, hasRspamdScore := headers["X-Rspamd-Score"]
|
|
||||||
if !hasSpamdResult && !hasRspamdScore {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &model.RspamdResult{
|
|
||||||
Symbols: make(map[string]model.SpamTestDetail),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
|
||||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
|
||||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
|
||||||
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
|
||||||
result.Report = &report
|
|
||||||
a.parseSpamdResult(spamdResult, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate symbol descriptions from the lookup map
|
|
||||||
if a.symbols != nil {
|
|
||||||
for name, sym := range result.Symbols {
|
|
||||||
if desc, ok := a.symbols[name]; ok {
|
|
||||||
sym.Description = &desc
|
|
||||||
result.Symbols[name] = sym
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive IsSpam from score vs reject threshold.
|
|
||||||
if result.Threshold > 0 {
|
|
||||||
result.IsSpam = result.Score >= result.Threshold
|
|
||||||
} 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 *model.RspamdResult) {
|
|
||||||
// Extract score and threshold from the first line
|
|
||||||
// e.g. "default: False [-3.91 / 15.00]"
|
|
||||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
|
||||||
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 := model.SpamTestDetail{
|
|
||||||
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 *model.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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed rspamd-symbols.json
|
|
||||||
var embeddedRspamdSymbols []byte
|
|
||||||
|
|
||||||
// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON.
|
|
||||||
type rspamdSymbolGroup struct {
|
|
||||||
Group string `json:"group"`
|
|
||||||
Rules []rspamdSymbolEntry `json:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// rspamdSymbolEntry represents a single rspamd symbol entry.
|
|
||||||
type rspamdSymbolEntry struct {
|
|
||||||
Symbol string `json:"symbol"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Weight float64 `json:"weight"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map.
|
|
||||||
func parseRspamdSymbolsJSON(data []byte) map[string]string {
|
|
||||||
var groups []rspamdSymbolGroup
|
|
||||||
if err := json.Unmarshal(data, &groups); err != nil {
|
|
||||||
log.Printf("Failed to parse rspamd symbols JSON: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
symbols := make(map[string]string, len(groups)*10)
|
|
||||||
for _, g := range groups {
|
|
||||||
for _, r := range g.Rules {
|
|
||||||
if r.Description != "" {
|
|
||||||
symbols[r.Symbol] = r.Description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return symbols
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadRspamdSymbols loads rspamd symbol descriptions.
|
|
||||||
// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error.
|
|
||||||
func LoadRspamdSymbols(apiURL string) map[string]string {
|
|
||||||
if apiURL != "" {
|
|
||||||
if symbols := fetchRspamdSymbols(apiURL); symbols != nil {
|
|
||||||
return symbols
|
|
||||||
}
|
|
||||||
log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL)
|
|
||||||
}
|
|
||||||
return parseRspamdSymbolsJSON(embeddedRspamdSymbols)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchRspamdSymbols fetches symbol descriptions from the rspamd API.
|
|
||||||
func fetchRspamdSymbols(apiURL string) map[string]string {
|
|
||||||
url := strings.TrimRight(apiURL, "/") + "/symbols"
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error fetching rspamd symbols: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
log.Printf("rspamd API returned status %d", resp.StatusCode)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading rspamd symbols response: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseRspamdSymbolsJSON(body)
|
|
||||||
}
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/mail"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
email := &EmailMessage{Header: make(mail.Header)}
|
|
||||||
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
|
||||||
|
|
||||||
if result != nil {
|
|
||||||
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSpamdResult(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
header string
|
|
||||||
expectedScore float32
|
|
||||||
expectedThreshold float32
|
|
||||||
expectedIsSpam bool
|
|
||||||
expectedSymbols map[string]float32
|
|
||||||
expectedSymParams map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Clean email negative score",
|
|
||||||
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
|
||||||
expectedScore: -3.91,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"DATE_IN_PAST": 0.10,
|
|
||||||
"ALL_TRUSTED": -1.00,
|
|
||||||
},
|
|
||||||
expectedSymParams: map[string]string{
|
|
||||||
"ALL_TRUSTED": "trusted",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Spam email True flag",
|
|
||||||
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
|
||||||
expectedScore: 16.50,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"BAYES_99": 5.00,
|
|
||||||
"SPOOFED_SENDER": 3.50,
|
|
||||||
},
|
|
||||||
expectedSymParams: map[string]string{
|
|
||||||
"BAYES_99": "1.00",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero threshold uses default",
|
|
||||||
header: "default: False [1.00 / 0.00]",
|
|
||||||
expectedScore: 1.00,
|
|
||||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Symbol without params",
|
|
||||||
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
|
||||||
expectedScore: 2.00,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"MISSING_DATE": 1.00,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Case-insensitive true flag",
|
|
||||||
header: "default: true [8.00 / 6.00]",
|
|
||||||
expectedScore: 8.00,
|
|
||||||
expectedThreshold: 6.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
expectedSymbols: map[string]float32{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero threshold with symbols containing nested brackets in params",
|
|
||||||
header: "default: False [0.90 / 0.00];\n" +
|
|
||||||
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
|
|
||||||
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
|
|
||||||
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
|
|
||||||
expectedScore: 0.90,
|
|
||||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"ARC_REJECT": 1.00,
|
|
||||||
"MIME_GOOD": -0.10,
|
|
||||||
"MIME_TRACE": 0.00,
|
|
||||||
},
|
|
||||||
expectedSymParams: map[string]string{
|
|
||||||
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
|
|
||||||
"MIME_GOOD": "multipart/alternative,text/plain",
|
|
||||||
"MIME_TRACE": "0:+,1:+,2:~",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := &model.RspamdResult{
|
|
||||||
Symbols: make(map[string]model.SpamTestDetail),
|
|
||||||
}
|
|
||||||
analyzer.parseSpamdResult(tt.header, result)
|
|
||||||
|
|
||||||
if result.Score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
if result.Threshold != tt.expectedThreshold {
|
|
||||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
|
||||||
}
|
|
||||||
if result.IsSpam != tt.expectedIsSpam {
|
|
||||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
|
||||||
}
|
|
||||||
for symName, expectedScore := range tt.expectedSymbols {
|
|
||||||
sym, ok := result.Symbols[symName]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("Symbol %s not found", symName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sym.Score != expectedScore {
|
|
||||||
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for symName, expectedParam := range tt.expectedSymParams {
|
|
||||||
sym, ok := result.Symbols[symName]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("Symbol %s not found for params check", symName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sym.Params == nil {
|
|
||||||
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
|
||||||
} else if *sym.Params != expectedParam {
|
|
||||||
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalyzeRspamd(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers map[string]string
|
|
||||||
expectedScore float32
|
|
||||||
expectedThreshold float32
|
|
||||||
expectedIsSpam bool
|
|
||||||
expectedServer *string
|
|
||||||
expectedSymCount int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Full headers clean email",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
|
||||||
"X-Rspamd-Score": "-3.91",
|
|
||||||
"X-Rspamd-Server": "mail.example.com",
|
|
||||||
},
|
|
||||||
expectedScore: -3.91,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
|
||||||
expectedSymCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Rspamd-Score overrides spamd result score",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
|
||||||
"X-Rspamd-Score": "3.50",
|
|
||||||
},
|
|
||||||
expectedScore: 3.50,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Spam email above threshold",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
|
||||||
"X-Rspamd-Score": "16.00",
|
|
||||||
},
|
|
||||||
expectedScore: 16.00,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
expectedSymCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Rspamd-Score": "2.00",
|
|
||||||
},
|
|
||||||
expectedScore: 2.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Rspamd-Score": "7.00",
|
|
||||||
},
|
|
||||||
expectedScore: 7.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Server header is trimmed",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Rspamd-Score": "1.00",
|
|
||||||
"X-Rspamd-Server": " rspamd-01 ",
|
|
||||||
},
|
|
||||||
expectedScore: 1.00,
|
|
||||||
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
email := &EmailMessage{Header: make(mail.Header)}
|
|
||||||
for k, v := range tt.headers {
|
|
||||||
email.Header[k] = []string{v}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected non-nil result")
|
|
||||||
}
|
|
||||||
if result.Score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
|
||||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
|
||||||
}
|
|
||||||
if result.IsSpam != tt.expectedIsSpam {
|
|
||||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
|
||||||
}
|
|
||||||
if tt.expectedServer != nil {
|
|
||||||
if result.Server == nil {
|
|
||||||
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
|
||||||
} else if *result.Server != *tt.expectedServer {
|
|
||||||
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
|
||||||
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateRspamdScore(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
result *model.RspamdResult
|
|
||||||
expectedScore int
|
|
||||||
expectedGrade string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Nil result (rspamd not installed)",
|
|
||||||
result: nil,
|
|
||||||
expectedScore: 100,
|
|
||||||
expectedGrade: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score well below threshold",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: -3.91,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
expectedScore: 100,
|
|
||||||
expectedGrade: "A+",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score at zero",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 0,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
|
||||||
expectedScore: 100,
|
|
||||||
expectedGrade: "A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score at threshold (half of 2*threshold)",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 15.00,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
|
||||||
expectedScore: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score above 2*threshold",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 31.00,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
expectedScore: 0,
|
|
||||||
expectedGrade: "F",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score exactly at 2*threshold",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 30.00,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
// 100 - round(30*100/30) = 100 - 100 = 0
|
|
||||||
expectedScore: 0,
|
|
||||||
expectedGrade: "F",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
|
||||||
|
|
||||||
if score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
|
||||||
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
|
||||||
BAYES_HAM(-3.00)[99%];
|
|
||||||
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
|
||||||
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
|
||||||
FROM_HAS_DN(0.00)[];
|
|
||||||
MIME_GOOD(-0.10)[text/plain];
|
|
||||||
X-Rspamd-Score: -3.91
|
|
||||||
X-Rspamd-Server: rspamd-01.example.com
|
|
||||||
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
|
||||||
From: sender@example.com
|
|
||||||
To: test@happydomain.org
|
|
||||||
Subject: Test email
|
|
||||||
Message-ID: <test123@example.com>
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
Hello world`
|
|
||||||
|
|
||||||
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
|
||||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected non-nil result")
|
|
||||||
}
|
|
||||||
if result.IsSpam {
|
|
||||||
t.Error("Expected IsSpam=false")
|
|
||||||
}
|
|
||||||
if result.Score != -3.91 {
|
|
||||||
t.Errorf("Score = %v, want -3.91", result.Score)
|
|
||||||
}
|
|
||||||
if result.Threshold != 15.00 {
|
|
||||||
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
|
||||||
}
|
|
||||||
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
|
||||||
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
|
||||||
for _, sym := range expectedSymbols {
|
|
||||||
if _, ok := result.Symbols[sym]; !ok {
|
|
||||||
t.Errorf("Symbol %s not found", sym)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
score, _ := analyzer.CalculateRspamdScore(result)
|
|
||||||
if score != 100 {
|
|
||||||
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
||||||
|
|
@ -65,37 +65,7 @@ func ScoreToGradeKind(score int) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoreToReportGrade converts a percentage score to an model.ReportGrade
|
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
|
||||||
func ScoreToReportGrade(score int) model.ReportGrade {
|
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||||
return model.ReportGrade(ScoreToGrade(score))
|
return api.ReportGrade(ScoreToGrade(score))
|
||||||
}
|
|
||||||
|
|
||||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
|
||||||
func gradeRank(grade string) int {
|
|
||||||
switch grade {
|
|
||||||
case "A++":
|
|
||||||
return 7
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
||||||
|
|
@ -40,26 +39,18 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
||||||
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
|
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
|
||||||
headers := email.GetSpamAssassinHeaders()
|
headers := email.GetSpamAssassinHeaders()
|
||||||
if len(headers) == 0 {
|
if len(headers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
|
result := &api.SpamAssassinResult{
|
||||||
_, hasStatus := headers["X-Spam-Status"]
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
_, hasScore := headers["X-Spam-Score"]
|
|
||||||
_, hasFlag := headers["X-Spam-Flag"]
|
|
||||||
if !hasStatus && !hasScore && !hasFlag {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &model.SpamAssassinResult{
|
|
||||||
TestDetails: make(map[string]model.SpamTestDetail),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Status header
|
// Parse X-Spam-Status header
|
||||||
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
|
if statusHeader, ok := headers["X-Spam-Status"]; ok {
|
||||||
a.parseSpamStatus(statusHeader, result)
|
a.parseSpamStatus(statusHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,13 +68,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
|
||||||
|
|
||||||
// Parse X-Spam-Report header for detailed test results
|
// Parse X-Spam-Report header for detailed test results
|
||||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||||
result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
||||||
a.parseSpamReport(reportHeader, result)
|
a.parseSpamReport(reportHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Checker-Version
|
// Parse X-Spam-Checker-Version
|
||||||
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
||||||
result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
|
result.Version = api.PtrTo(strings.TrimSpace(versionHeader))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -91,7 +82,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
|
||||||
|
|
||||||
// parseSpamStatus parses the X-Spam-Status header
|
// parseSpamStatus parses the X-Spam-Status header
|
||||||
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) {
|
||||||
// Check if spam (first word)
|
// Check if spam (first word)
|
||||||
parts := strings.SplitN(header, ",", 2)
|
parts := strings.SplitN(header, ",", 2)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
|
|
@ -135,7 +126,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
|
||||||
// * 0.0 TEST_NAME Description line 1
|
// * 0.0 TEST_NAME Description line 1
|
||||||
// * continuation line 2
|
// * continuation line 2
|
||||||
// * continuation line 3
|
// * continuation line 3
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
|
||||||
segments := strings.Split(report, "*")
|
segments := strings.Split(report, "*")
|
||||||
|
|
||||||
// Regex to match test lines: score TEST_NAME Description
|
// Regex to match test lines: score TEST_NAME Description
|
||||||
|
|
@ -157,7 +148,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
// Save previous test if exists
|
// Save previous test if exists
|
||||||
if currentTestName != "" {
|
if currentTestName != "" {
|
||||||
description := strings.TrimSpace(currentDescription.String())
|
description := strings.TrimSpace(currentDescription.String())
|
||||||
detail := model.SpamTestDetail{
|
detail := api.SpamTestDetail{
|
||||||
Name: currentTestName,
|
Name: currentTestName,
|
||||||
Score: result.TestDetails[currentTestName].Score,
|
Score: result.TestDetails[currentTestName].Score,
|
||||||
Description: &description,
|
Description: &description,
|
||||||
|
|
@ -175,7 +166,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
currentDescription.WriteString(description)
|
currentDescription.WriteString(description)
|
||||||
|
|
||||||
// Initialize with score
|
// Initialize with score
|
||||||
result.TestDetails[testName] = model.SpamTestDetail{
|
result.TestDetails[testName] = api.SpamTestDetail{
|
||||||
Name: testName,
|
Name: testName,
|
||||||
Score: float32(score),
|
Score: float32(score),
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +183,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
// Save the last test if exists
|
// Save the last test if exists
|
||||||
if currentTestName != "" {
|
if currentTestName != "" {
|
||||||
description := strings.TrimSpace(currentDescription.String())
|
description := strings.TrimSpace(currentDescription.String())
|
||||||
detail := model.SpamTestDetail{
|
detail := api.SpamTestDetail{
|
||||||
Name: currentTestName,
|
Name: currentTestName,
|
||||||
Score: result.TestDetails[currentTestName].Score,
|
Score: result.TestDetails[currentTestName].Score,
|
||||||
Description: &description,
|
Description: &description,
|
||||||
|
|
@ -202,7 +193,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
||||||
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
|
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return 100, "" // No spam scan results, assume good
|
return 100, "" // No spam scan results, assume good
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSpamStatus(t *testing.T) {
|
func TestParseSpamStatus(t *testing.T) {
|
||||||
|
|
@ -78,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := &model.SpamAssassinResult{
|
result := &api.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]model.SpamTestDetail),
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
analyzer.parseSpamStatus(tt.header, result)
|
analyzer.parseSpamStatus(tt.header, result)
|
||||||
|
|
||||||
|
|
@ -116,27 +115,27 @@ func TestParseSpamReport(t *testing.T) {
|
||||||
`
|
`
|
||||||
|
|
||||||
analyzer := NewSpamAssassinAnalyzer()
|
analyzer := NewSpamAssassinAnalyzer()
|
||||||
result := &model.SpamAssassinResult{
|
result := &api.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]model.SpamTestDetail),
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer.parseSpamReport(report, result)
|
analyzer.parseSpamReport(report, result)
|
||||||
|
|
||||||
expectedTests := map[string]model.SpamTestDetail{
|
expectedTests := map[string]api.SpamTestDetail{
|
||||||
"BAYES_99": {
|
"BAYES_99": {
|
||||||
Name: "BAYES_99",
|
Name: "BAYES_99",
|
||||||
Score: 5.0,
|
Score: 5.0,
|
||||||
Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
|
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
|
||||||
},
|
},
|
||||||
"SPOOFED_SENDER": {
|
"SPOOFED_SENDER": {
|
||||||
Name: "SPOOFED_SENDER",
|
Name: "SPOOFED_SENDER",
|
||||||
Score: 3.5,
|
Score: 3.5,
|
||||||
Description: utils.PtrTo("From address doesn't match envelope sender"),
|
Description: api.PtrTo("From address doesn't match envelope sender"),
|
||||||
},
|
},
|
||||||
"ALL_TRUSTED": {
|
"ALL_TRUSTED": {
|
||||||
Name: "ALL_TRUSTED",
|
Name: "ALL_TRUSTED",
|
||||||
Score: -1.0,
|
Score: -1.0,
|
||||||
Description: utils.PtrTo("All mail servers are trusted"),
|
Description: api.PtrTo("All mail servers are trusted"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +157,7 @@ func TestParseSpamReport(t *testing.T) {
|
||||||
func TestGetSpamAssassinScore(t *testing.T) {
|
func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
result *model.SpamAssassinResult
|
result *api.SpamAssassinResult
|
||||||
expectedScore int
|
expectedScore int
|
||||||
minScore int
|
minScore int
|
||||||
maxScore int
|
maxScore int
|
||||||
|
|
@ -170,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Excellent score (negative)",
|
name: "Excellent score (negative)",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: -2.5,
|
Score: -2.5,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -178,7 +177,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Good score (below threshold)",
|
name: "Good score (below threshold)",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 2.0,
|
Score: 2.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -186,7 +185,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score at threshold",
|
name: "Score at threshold",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 5.0,
|
Score: 5.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -194,7 +193,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Above threshold (spam)",
|
name: "Above threshold (spam)",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 6.0,
|
Score: 6.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -202,7 +201,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "High spam score",
|
name: "High spam score",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 12.0,
|
Score: 12.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +209,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Very high spam score",
|
name: "Very high spam score",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 20.0,
|
Score: 20.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
2542
web/package-lock.json
generated
2542
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,24 +16,24 @@
|
||||||
"generate:api": "openapi-ts"
|
"generate:api": "openapi-ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@hey-api/openapi-ts": "0.86.10",
|
"@hey-api/openapi-ts": "0.86.10",
|
||||||
"@sveltejs/adapter-static": "^3.0.9",
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^9.38.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.12.4",
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
"globals": "^17.0.0",
|
"globals": "^16.4.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.44.1",
|
"typescript-eslint": "^8.44.1",
|
||||||
"vite": "^8.0.0",
|
"vite": "^7.1.10",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -66,14 +67,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||||
appConfig["rbls"] = cfg.Analysis.RBLs
|
appConfig["rbls"] = cfg.Analysis.RBLs
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.CustomLogoURL != "" {
|
|
||||||
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg.DisableTestList {
|
|
||||||
appConfig["test_list_enabled"] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
||||||
log.Println("Unable to generate JSON config to inject in web application")
|
log.Println("Unable to generate JSON config to inject in web application")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -99,7 +92,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||||
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
||||||
router.GET("/test/", serveOrReverse("/", cfg))
|
router.GET("/test/", serveOrReverse("/", cfg))
|
||||||
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
||||||
router.GET("/history/", serveOrReverse("/", cfg))
|
|
||||||
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||||
router.GET("/img/*path", serveOrReverse("", cfg))
|
router.GET("/img/*path", serveOrReverse("", cfg))
|
||||||
|
|
||||||
|
|
@ -148,7 +140,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
v, _ := io.ReadAll(resp.Body)
|
v, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
||||||
|
|
||||||
|
|
@ -175,7 +167,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
||||||
if indexTpl == nil {
|
if indexTpl == nil {
|
||||||
// Create template from file
|
// Create template from file
|
||||||
f, _ := Assets.Open("index.html")
|
f, _ := Assets.Open("index.html")
|
||||||
v, _ := io.ReadAll(f)
|
v, _ := ioutil.ReadAll(f)
|
||||||
|
|
||||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
:root {
|
:root {
|
||||||
--bs-primary: #1cb487;
|
--bs-primary: #1cb487;
|
||||||
--bs-primary-rgb: 28, 180, 135;
|
--bs-primary-rgb: 28, 180, 135;
|
||||||
--bs-link-color-rgb: 28, 180, 135;
|
|
||||||
--bs-link-hover-color-rgb: 17, 112, 84;
|
|
||||||
--bs-tertiary-bg: #e7e8e8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -11,10 +8,6 @@ body {
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-tertiary {
|
|
||||||
background-color: var(--bs-tertiary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,12 @@
|
||||||
|
|
||||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||||
|
|
||||||
let allRequiredMissing = $derived(
|
|
||||||
!authentication.spf &&
|
|
||||||
(!authentication.dkim || authentication.dkim.length === 0) &&
|
|
||||||
!authentication.dmarc,
|
|
||||||
);
|
|
||||||
|
|
||||||
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case "pass":
|
case "pass":
|
||||||
case "domain_pass":
|
case "domain_pass":
|
||||||
case "orgdomain_pass":
|
case "orgdomain_pass":
|
||||||
return "text-success";
|
return "text-success";
|
||||||
case "permerror":
|
|
||||||
case "error":
|
case "error":
|
||||||
case "fail":
|
case "fail":
|
||||||
case "missing":
|
case "missing":
|
||||||
|
|
@ -58,7 +51,6 @@
|
||||||
case "neutral":
|
case "neutral":
|
||||||
case "invalid":
|
case "invalid":
|
||||||
case "null":
|
case "null":
|
||||||
case "permerror":
|
|
||||||
case "error":
|
case "error":
|
||||||
case "null_smtp":
|
case "null_smtp":
|
||||||
case "null_header":
|
case "null_header":
|
||||||
|
|
@ -103,465 +95,282 @@
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
{#if allRequiredMissing}
|
|
||||||
<div class="card-body border-bottom">
|
|
||||||
<div class="alert alert-warning mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
|
||||||
<strong>No authentication results found.</strong>
|
|
||||||
<p class="mb-0 mt-1">
|
|
||||||
This usually means either:
|
|
||||||
</p>
|
|
||||||
<ul class="mb-0 mt-1">
|
|
||||||
<li>
|
|
||||||
The receiving mail server is not configured to verify email authentication
|
|
||||||
(no <code>Authentication-Results</code> header was found in the message).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The <code>Authentication-Results</code> header exists but the receiver
|
|
||||||
hostname does not match the configured
|
|
||||||
<code>--receiver-hostname</code> value.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<!-- IPREV -->
|
<!-- IPREV -->
|
||||||
{#if authentication.iprev}
|
{#if authentication.iprev}
|
||||||
<div class="list-group-item" id="authentication-iprev">
|
<div class="list-group-item" id="authentication-iprev">
|
||||||
<div class="d-flex align-items-start">
|
<div class="d-flex align-items-start">
|
||||||
<i
|
<i class="bi {getAuthResultIcon(authentication.iprev.result, true)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"></i>
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.iprev.result,
|
|
||||||
true,
|
|
||||||
)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>IP Reverse DNS</strong>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.iprev.result,
|
|
||||||
true,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.iprev.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.iprev.ip}
|
|
||||||
<div class="small">
|
|
||||||
<strong>IP Address:</strong>
|
|
||||||
<span class="text-muted">{authentication.iprev.ip}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.iprev.hostname}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Hostname:</strong>
|
|
||||||
<span class="text-muted">{authentication.iprev.hostname}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.iprev.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.iprev.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- SPF (Required) -->
|
|
||||||
<div class="list-group-item">
|
|
||||||
<div class="d-flex align-items-start" id="authentication-spf">
|
|
||||||
{#if authentication.spf}
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.spf.result,
|
|
||||||
true,
|
|
||||||
)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>SPF</strong>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.spf.result,
|
|
||||||
true,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.spf.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.spf.domain}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Domain:</strong>
|
|
||||||
<span class="text-muted">{authentication.spf.domain}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.spf.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.spf.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
|
|
||||||
'missing',
|
|
||||||
true,
|
|
||||||
)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>SPF</strong>
|
|
||||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
|
||||||
{getAuthResultText("missing")}
|
|
||||||
</span>
|
|
||||||
<div class="text-muted small">
|
|
||||||
SPF record is required for proper email authentication
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DKIM (Required) -->
|
|
||||||
<div class="list-group-item" id="authentication-dkim">
|
|
||||||
{#if authentication.dkim && authentication.dkim.length > 0}
|
|
||||||
{#each authentication.dkim as dkim, i}
|
|
||||||
<div class="d-flex align-items-start" class:mt-3={i > 0}>
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(
|
|
||||||
dkim.result,
|
|
||||||
true,
|
|
||||||
)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""}</strong
|
<strong>IP Reverse DNS</strong>
|
||||||
>
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.iprev.result, true)}">
|
||||||
<span
|
{authentication.iprev.result}
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}"
|
|
||||||
>
|
|
||||||
{dkim.result}
|
|
||||||
</span>
|
</span>
|
||||||
{#if dkim.domain}
|
{#if authentication.iprev.ip}
|
||||||
<div class="small">
|
<div class="small">
|
||||||
<strong>Domain:</strong>
|
<strong>IP Address:</strong>
|
||||||
<span class="text-muted">{dkim.domain}</span>
|
<span class="text-muted">{authentication.iprev.ip}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dkim.selector}
|
{#if authentication.iprev.hostname}
|
||||||
<div class="small">
|
<div class="small">
|
||||||
<strong>Selector:</strong>
|
<strong>Hostname:</strong>
|
||||||
<span class="text-muted">{dkim.selector}</span>
|
<span class="text-muted">{authentication.iprev.hostname}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dkim.details}
|
{#if authentication.iprev.details}
|
||||||
<pre
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.iprev.details}</pre>
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{dkim.details}</pre>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
|
|
||||||
'missing',
|
|
||||||
true,
|
|
||||||
)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>DKIM</strong>
|
|
||||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
|
||||||
{getAuthResultText("missing")}
|
|
||||||
</span>
|
|
||||||
<div class="text-muted small">
|
|
||||||
DKIM signature is required for proper email authentication
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- X-Google-DKIM (Optional) -->
|
<!-- SPF (Required) -->
|
||||||
{#if authentication.x_google_dkim}
|
<div class="list-group-item">
|
||||||
<div class="list-group-item" id="authentication-x-google-dkim">
|
<div class="d-flex align-items-start" id="authentication-spf">
|
||||||
<div class="d-flex align-items-start">
|
{#if authentication.spf}
|
||||||
<i
|
<i class="bi {getAuthResultIcon(authentication.spf.result, true)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"></i>
|
||||||
class="bi {getAuthResultIcon(
|
<div>
|
||||||
authentication.x_google_dkim.result,
|
<strong>SPF</strong>
|
||||||
false,
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.spf.result, true)}">
|
||||||
)} {getAuthResultClass(
|
{authentication.spf.result}
|
||||||
authentication.x_google_dkim.result,
|
</span>
|
||||||
false,
|
{#if authentication.spf.domain}
|
||||||
)} me-2 fs-5"
|
<div class="small">
|
||||||
></i>
|
<strong>Domain:</strong>
|
||||||
<div>
|
<span class="text-muted">{authentication.spf.domain}</span>
|
||||||
<strong>X-Google-DKIM</strong>
|
</div>
|
||||||
<i
|
{/if}
|
||||||
class="bi bi-info-circle text-muted ms-1"
|
{#if authentication.spf.details}
|
||||||
title="Google's internal DKIM signature for messages routed through Gmail infrastructure"
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.spf.details}</pre>
|
||||||
></i>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.x_google_dkim.result,
|
|
||||||
false,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.x_google_dkim.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.x_google_dkim.domain}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Domain:</strong>
|
|
||||||
<span class="text-muted">{authentication.x_google_dkim.domain}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.x_google_dkim.selector}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Selector:</strong>
|
|
||||||
<span class="text-muted"
|
|
||||||
>{authentication.x_google_dkim.selector}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.x_google_dkim.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.x_google_dkim
|
|
||||||
.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- X-Aligned-From (Disabled) -->
|
|
||||||
{#if authentication.x_aligned_from}
|
|
||||||
<div class="list-group-item" id="authentication-x-aligned-from">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.x_aligned_from.result,
|
|
||||||
false,
|
|
||||||
)} {getAuthResultClass(
|
|
||||||
authentication.x_aligned_from.result,
|
|
||||||
false,
|
|
||||||
)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>X-Aligned-From</strong>
|
|
||||||
<i
|
|
||||||
class="bi bi-info-circle text-muted ms-1"
|
|
||||||
title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."
|
|
||||||
></i>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.x_aligned_from.result,
|
|
||||||
false,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.x_aligned_from.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.x_aligned_from.domain}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Domain:</strong>
|
|
||||||
<span class="text-muted"
|
|
||||||
>{authentication.x_aligned_from.domain}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.x_aligned_from.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.x_aligned_from
|
|
||||||
.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- DMARC (Required) -->
|
|
||||||
<div class="list-group-item" id="authentication-dmarc">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
{#if authentication.dmarc}
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.dmarc.result,
|
|
||||||
true,
|
|
||||||
)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>DMARC</strong>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.dmarc.result,
|
|
||||||
true,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.dmarc.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.dmarc.domain}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Domain:</strong>
|
|
||||||
<span class="text-muted">{authentication.dmarc.domain}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#snippet DMARCPolicy(policy: string)}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Policy:</strong>
|
|
||||||
<span
|
|
||||||
class="fw-bold"
|
|
||||||
class:text-success={policy == "reject"}
|
|
||||||
class:text-warning={policy == "quarantine"}
|
|
||||||
class:text-danger={policy == "none"}
|
|
||||||
class:bg-warning={policy != "none" &&
|
|
||||||
policy != "quarantine" &&
|
|
||||||
policy != "reject"}
|
|
||||||
>
|
|
||||||
{policy}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
{#if authentication.dmarc.result != "none"}
|
|
||||||
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
|
|
||||||
{@const policy = authentication.dmarc.details.replace(
|
|
||||||
/^.*policy.published-domain-policy=([^\s]+).*$/,
|
|
||||||
"$1",
|
|
||||||
)}
|
|
||||||
{@render DMARCPolicy(policy)}
|
|
||||||
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
|
|
||||||
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
{#if authentication.dmarc.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
|
|
||||||
'missing',
|
|
||||||
true,
|
|
||||||
)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>DMARC</strong>
|
|
||||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
|
||||||
{getAuthResultText("missing")}
|
|
||||||
</span>
|
|
||||||
<div class="text-muted small">
|
|
||||||
DMARC policy is required for proper email authentication
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
{/if}
|
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<strong>SPF</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||||
<!-- BIMI (Optional) -->
|
{getAuthResultText('missing')}
|
||||||
<div class="list-group-item" id="authentication-bimi">
|
</span>
|
||||||
<div class="d-flex align-items-start">
|
<div class="text-muted small">SPF record is required for proper email authentication</div>
|
||||||
{#if authentication.bimi && authentication.bimi.result != "none"}
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.bimi.result,
|
|
||||||
false,
|
|
||||||
)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>BIMI</strong>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.bimi.result,
|
|
||||||
false,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.bimi.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.bimi.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if authentication.bimi && authentication.bimi.result == "none"}
|
|
||||||
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
|
|
||||||
<div>
|
|
||||||
<strong>BIMI</strong>
|
|
||||||
<span class="text-uppercase ms-2 text-warning"> NONE </span>
|
|
||||||
<div class="text-muted small">
|
|
||||||
Brand Indicators for Message Identification
|
|
||||||
</div>
|
</div>
|
||||||
{#if authentication.bimi.details}
|
{/if}
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
|
|
||||||
<div>
|
|
||||||
<strong>BIMI</strong>
|
|
||||||
<span class="text-uppercase ms-2 text-muted"> Optional </span>
|
|
||||||
<div class="text-muted small">
|
|
||||||
Brand Indicators for Message Identification (optional enhancement)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ARC (Optional) -->
|
|
||||||
{#if authentication.arc}
|
|
||||||
<div class="list-group-item" id="authentication-arc">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.arc.result,
|
|
||||||
false,
|
|
||||||
)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>ARC</strong>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.arc.result,
|
|
||||||
false,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.arc.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.arc.chain_length}
|
|
||||||
<div class="text-muted small">
|
|
||||||
Chain length: {authentication.arc.chain_length}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.arc.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.arc.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
<!-- DKIM (Required) -->
|
||||||
|
<div class="list-group-item" id="authentication-dkim">
|
||||||
|
{#if authentication.dkim && authentication.dkim.length > 0}
|
||||||
|
{#each authentication.dkim as dkim, i}
|
||||||
|
<div class="d-flex align-items-start" class:mt-3={i > 0}>
|
||||||
|
<i class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(dkim.result, true)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ''}</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}">
|
||||||
|
{dkim.result}
|
||||||
|
</span>
|
||||||
|
{#if dkim.domain}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Domain:</strong>
|
||||||
|
<span class="text-muted">{dkim.domain}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if dkim.selector}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Selector:</strong>
|
||||||
|
<span class="text-muted">{dkim.selector}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if dkim.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{dkim.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>DKIM</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||||
|
{getAuthResultText('missing')}
|
||||||
|
</span>
|
||||||
|
<div class="text-muted small">DKIM signature is required for proper email authentication</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- X-Google-DKIM (Optional) -->
|
||||||
|
{#if authentication.x_google_dkim}
|
||||||
|
<div class="list-group-item" id="authentication-x-google-dkim">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi {getAuthResultIcon(authentication.x_google_dkim.result, false)} {getAuthResultClass(authentication.x_google_dkim.result, false)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>X-Google-DKIM</strong>
|
||||||
|
<i class="bi bi-info-circle text-muted ms-1" title="Google's internal DKIM signature for messages routed through Gmail infrastructure"></i>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_google_dkim.result, false)}">
|
||||||
|
{authentication.x_google_dkim.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.x_google_dkim.domain}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Domain:</strong>
|
||||||
|
<span class="text-muted">{authentication.x_google_dkim.domain}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authentication.x_google_dkim.selector}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Selector:</strong>
|
||||||
|
<span class="text-muted">{authentication.x_google_dkim.selector}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authentication.x_google_dkim.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.x_google_dkim.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- X-Aligned-From (Disabled) -->
|
||||||
|
{#if authentication.x_aligned_from}
|
||||||
|
<div class="list-group-item" id="authentication-x-aligned-from">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi {getAuthResultIcon(authentication.x_aligned_from.result, false)} {getAuthResultClass(authentication.x_aligned_from.result, false)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>X-Aligned-From</strong>
|
||||||
|
<i class="bi bi-info-circle text-muted ms-1" title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."></i>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_aligned_from.result, false)}">
|
||||||
|
{authentication.x_aligned_from.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.x_aligned_from.domain}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Domain:</strong>
|
||||||
|
<span class="text-muted">{authentication.x_aligned_from.domain}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authentication.x_aligned_from.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.x_aligned_from.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- DMARC (Required) -->
|
||||||
|
<div class="list-group-item" id="authentication-dmarc">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
{#if authentication.dmarc}
|
||||||
|
<i class="bi {getAuthResultIcon(authentication.dmarc.result, true)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>DMARC</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.dmarc.result, true)}">
|
||||||
|
{authentication.dmarc.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.dmarc.domain}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Domain:</strong>
|
||||||
|
<span class="text-muted">{authentication.dmarc.domain}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#snippet DMARCPolicy(policy: string)}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Policy:</strong>
|
||||||
|
<span
|
||||||
|
class="fw-bold"
|
||||||
|
class:text-success={policy == "reject"}
|
||||||
|
class:text-warning={policy == "quarantine"}
|
||||||
|
class:text-danger={policy == "none"}
|
||||||
|
class:bg-warning={policy != "none" && policy != "quarantine" && policy != "reject"}
|
||||||
|
>
|
||||||
|
{policy}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#if authentication.dmarc.result != "none"}
|
||||||
|
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
|
||||||
|
{@const policy = authentication.dmarc.details.replace(/^.*policy.published-domain-policy=([^\s]+).*$/, "$1")}
|
||||||
|
{@render DMARCPolicy(policy)}
|
||||||
|
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
|
||||||
|
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if authentication.dmarc.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>DMARC</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||||
|
{getAuthResultText('missing')}
|
||||||
|
</span>
|
||||||
|
<div class="text-muted small">DMARC policy is required for proper email authentication</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BIMI (Optional) -->
|
||||||
|
<div class="list-group-item" id="authentication-bimi">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
{#if authentication.bimi && authentication.bimi.result != "none"}
|
||||||
|
<i class="bi {getAuthResultIcon(authentication.bimi.result, false)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>BIMI</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.bimi.result, false)}">
|
||||||
|
{authentication.bimi.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.bimi.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if authentication.bimi && authentication.bimi.result == "none"}
|
||||||
|
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>BIMI</strong>
|
||||||
|
<span class="text-uppercase ms-2 text-warning">
|
||||||
|
NONE
|
||||||
|
</span>
|
||||||
|
<div class="text-muted small">Brand Indicators for Message Identification</div>
|
||||||
|
{#if authentication.bimi.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>BIMI</strong>
|
||||||
|
<span class="text-uppercase ms-2 text-muted">
|
||||||
|
Optional
|
||||||
|
</span>
|
||||||
|
<div class="text-muted small">Brand Indicators for Message Identification (optional enhancement)</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ARC (Optional) -->
|
||||||
|
{#if authentication.arc}
|
||||||
|
<div class="list-group-item" id="authentication-arc">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi {getAuthResultIcon(authentication.arc.result, false)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>ARC</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.arc.result, false)}">
|
||||||
|
{authentication.arc.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.arc.chain_length}
|
||||||
|
<div class="text-muted small">Chain length: {authentication.arc.chain_length}</div>
|
||||||
|
{/if}
|
||||||
|
{#if authentication.arc.details}
|
||||||
|
<pre class="p-2 mb-0 {$theme === 'light' ? 'bg-light' : 'bg-secondary'} text-muted small" style="white-space: pre-wrap">{authentication.arc.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
import EmailPathCard from "./EmailPathCard.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blacklists: Record<string, BlacklistCheck[]>;
|
blacklists: Record<string, BlacklistCheck[]>;
|
||||||
blacklistGrade?: string;
|
blacklistGrade?: string;
|
||||||
blacklistScore?: number;
|
blacklistScore?: number;
|
||||||
|
receivedChain?: ReceivedHop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="rbl-details">
|
<div class="card shadow-sm" id="rbl-details">
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
<div
|
||||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
class="card-header"
|
||||||
|
class:bg-white={$theme === 'light'}
|
||||||
|
class:bg-dark={$theme !== 'light'}
|
||||||
|
>
|
||||||
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-shield-exclamation me-2"></i>
|
<i class="bi bi-shield-exclamation me-2"></i>
|
||||||
Blacklist Checks
|
Blacklist Checks
|
||||||
|
|
@ -33,7 +39,11 @@
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
{#if receivedChain}
|
||||||
|
<EmailPathCard {receivedChain} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
{#each Object.entries(blacklists) as [ip, checks]}
|
{#each Object.entries(blacklists) as [ip, checks]}
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<h5 class="text-muted">
|
<h5 class="text-muted">
|
||||||
|
|
@ -44,19 +54,9 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each checks as check}
|
{#each checks as check}
|
||||||
<tr>
|
<tr>
|
||||||
<td title={check.response || "-"}>
|
<td title={check.response || '-'}>
|
||||||
<span
|
<span class="badge {check.listed ? 'bg-danger' : check.error ? 'bg-dark' : 'bg-success'}">
|
||||||
class="badge {check.listed
|
{check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')}
|
||||||
? 'bg-danger'
|
|
||||||
: check.error
|
|
||||||
? 'bg-dark'
|
|
||||||
: 'bg-success'}"
|
|
||||||
>
|
|
||||||
{check.error
|
|
||||||
? "Error"
|
|
||||||
: check.listed
|
|
||||||
? "Listed"
|
|
||||||
: "Clean"}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{check.rbl}</code></td>
|
<td><code>{check.rbl}</code></td>
|
||||||
|
|
|
||||||
|
|
@ -36,28 +36,16 @@
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex align-items-center mb-2">
|
<div class="d-flex align-items-center mb-2">
|
||||||
<i
|
<i class="bi {contentAnalysis.has_html ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
|
||||||
class="bi {contentAnalysis.has_html
|
|
||||||
? 'bi-check-circle text-success'
|
|
||||||
: 'bi-x-circle text-muted'} me-2"
|
|
||||||
></i>
|
|
||||||
<span>HTML Part</span>
|
<span>HTML Part</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center mb-2">
|
<div class="d-flex align-items-center mb-2">
|
||||||
<i
|
<i class="bi {contentAnalysis.has_plaintext ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
|
||||||
class="bi {contentAnalysis.has_plaintext
|
|
||||||
? 'bi-check-circle text-success'
|
|
||||||
: 'bi-x-circle text-muted'} me-2"
|
|
||||||
></i>
|
|
||||||
<span>Plaintext Part</span>
|
<span>Plaintext Part</span>
|
||||||
</div>
|
</div>
|
||||||
{#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
|
{#if typeof contentAnalysis.has_unsubscribe_link === 'boolean'}
|
||||||
<div class="d-flex align-items-center mb-2">
|
<div class="d-flex align-items-center mb-2">
|
||||||
<i
|
<i class="bi {contentAnalysis.has_unsubscribe_link ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'} me-2"></i>
|
||||||
class="bi {contentAnalysis.has_unsubscribe_link
|
|
||||||
? 'bi-check-circle text-success'
|
|
||||||
: 'bi-x-circle text-warning'} me-2"
|
|
||||||
></i>
|
|
||||||
<span>Unsubscribe Link</span>
|
<span>Unsubscribe Link</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -86,14 +74,7 @@
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<h5>Content Issues</h5>
|
<h5>Content Issues</h5>
|
||||||
{#each contentAnalysis.html_issues as issue}
|
{#each contentAnalysis.html_issues as issue}
|
||||||
<div
|
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
|
||||||
class="alert alert-{issue.severity === 'critical' ||
|
|
||||||
issue.severity === 'high'
|
|
||||||
? 'danger'
|
|
||||||
: issue.severity === 'medium'
|
|
||||||
? 'warning'
|
|
||||||
: 'info'} py-2 px-3 mb-2"
|
|
||||||
>
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<strong>{issue.type}</strong>
|
<strong>{issue.type}</strong>
|
||||||
|
|
@ -137,17 +118,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span class="badge {link.status === 'valid' ? 'bg-success' : link.status === 'broken' ? 'bg-danger' : 'bg-warning'}">
|
||||||
class="badge {link.status === 'valid'
|
|
||||||
? 'bg-success'
|
|
||||||
: link.status === 'broken'
|
|
||||||
? 'bg-danger'
|
|
||||||
: 'bg-warning'}"
|
|
||||||
>
|
|
||||||
{link.status}
|
{link.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{link.http_code || "-"}</td>
|
<td>{link.http_code || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -171,11 +146,11 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each contentAnalysis.images as image}
|
{#each contentAnalysis.images as image}
|
||||||
<tr>
|
<tr>
|
||||||
<td><small class="text-break">{image.src || "-"}</small></td>
|
<td><small class="text-break">{image.src || '-'}</small></td>
|
||||||
<td>
|
<td>
|
||||||
{#if image.has_alt}
|
{#if image.has_alt}
|
||||||
<i class="bi bi-check-circle text-success me-1"></i>
|
<i class="bi bi-check-circle text-success me-1"></i>
|
||||||
<small>{image.alt_text || "Present"}</small>
|
<small>{image.alt_text || 'Present'}</small>
|
||||||
{:else}
|
{:else}
|
||||||
<i class="bi bi-x-circle text-warning me-1"></i>
|
<i class="bi bi-x-circle text-warning me-1"></i>
|
||||||
<small class="text-muted">Missing</small>
|
<small class="text-muted">Missing</small>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DnsResults, DomainAlignment, ReceivedHop } from "$lib/api/types.gen";
|
import type { DomainAlignment, DnsResults, ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
|
|
||||||
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
|
|
||||||
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||||
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
|
||||||
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
|
||||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||||
|
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
|
||||||
|
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
|
||||||
|
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
|
||||||
|
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
||||||
|
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
||||||
|
import DnssecDisplay from "./DnssecDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
domainAlignment?: DomainAlignment;
|
domainAlignment?: DomainAlignment;
|
||||||
|
|
@ -20,14 +21,7 @@
|
||||||
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
|
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { domainAlignment, dnsResults, dnsGrade, dnsScore, receivedChain, domainOnly = false }: Props = $props();
|
||||||
domainAlignment,
|
|
||||||
dnsResults,
|
|
||||||
dnsGrade,
|
|
||||||
dnsScore,
|
|
||||||
receivedChain,
|
|
||||||
domainOnly = false,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Extract sender IP from first hop
|
// Extract sender IP from first hop
|
||||||
const senderIp = $derived(
|
const senderIp = $derived(
|
||||||
|
|
@ -74,10 +68,7 @@
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
<div class="mb-3 d-flex align-items-center gap-2">
|
<div class="mb-3 d-flex align-items-center gap-2">
|
||||||
<h4 class="mb-0 text-truncate">
|
<h4 class="mb-0 text-truncate">
|
||||||
Received from: <code
|
Received from: <code>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0].ip}])</code>
|
||||||
>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0]
|
|
||||||
.ip}])</code
|
|
||||||
>
|
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -98,13 +89,10 @@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h4 class="mb-0 text-truncate">
|
<h4 class="mb-0 text-truncate">
|
||||||
Return-Path Domain:
|
Return-Path Domain: <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||||
<code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
|
||||||
</h4>
|
</h4>
|
||||||
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
|
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
|
||||||
<span class="badge bg-danger ms-2">
|
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain</span>
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain
|
|
||||||
</span>
|
|
||||||
<small>
|
<small>
|
||||||
<i class="bi bi-chevron-right"></i>
|
<i class="bi bi-chevron-right"></i>
|
||||||
<a href="#domain-alignment">See domain alignment</a>
|
<a href="#domain-alignment">See domain alignment</a>
|
||||||
|
|
@ -127,13 +115,10 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- SPF Records (for Return-Path Domain) -->
|
<!-- SPF Records (for Return-Path Domain) -->
|
||||||
<SpfRecordsDisplay
|
<SpfRecordsDisplay spfRecords={dnsResults.spf_records} dmarcRecord={dnsResults.dmarc_record} />
|
||||||
spfRecords={dnsResults.spf_records}
|
|
||||||
dmarcRecord={dnsResults.dmarc_record}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !domainOnly}
|
{#if !domainOnly}
|
||||||
<hr class="my-4" />
|
<hr class="my-4">
|
||||||
|
|
||||||
<!-- From Domain Section -->
|
<!-- From Domain Section -->
|
||||||
<div class="mb-3 d-flex align-items-center gap-2">
|
<div class="mb-3 d-flex align-items-center gap-2">
|
||||||
|
|
@ -141,34 +126,34 @@
|
||||||
From Domain: <code>{dnsResults.from_domain}</code>
|
From Domain: <code>{dnsResults.from_domain}</code>
|
||||||
</h4>
|
</h4>
|
||||||
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||||
<span class="badge bg-danger ms-2">
|
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain</span>
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
|
|
||||||
domain
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- MX Records for From Domain -->
|
<!-- MX Records for From Domain -->
|
||||||
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
|
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
|
||||||
<MxRecordsDisplay
|
<MxRecordsDisplay
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
mxRecords={dnsResults.from_mx_records}
|
mxRecords={dnsResults.from_mx_records}
|
||||||
title="Mail Exchange Records for From Domain"
|
title="Mail Exchange Records for From Domain"
|
||||||
description="These MX records handle replies to emails sent from this domain."
|
description="These MX records handle replies to emails sent from this domain."
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !domainOnly}
|
{#if !domainOnly}
|
||||||
<!-- DKIM Records -->
|
<!-- DKIM Records -->
|
||||||
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
|
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- DMARC Record -->
|
<!-- DMARC Record -->
|
||||||
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
||||||
|
|
||||||
<!-- BIMI Record -->
|
<!-- BIMI Record -->
|
||||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||||
|
|
||||||
|
<!-- DNSSEC -->
|
||||||
|
<DnssecDisplay dnssecEnabled={dnsResults.dnssec_enabled} domain={dnsResults.from_domain} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
56
web/src/lib/components/DnssecDisplay.svelte
Normal file
56
web/src/lib/components/DnssecDisplay.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
dnssecEnabled?: boolean;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { dnssecEnabled, domain }: Props = $props();
|
||||||
|
|
||||||
|
// DNSSEC is valid if it's explicitly enabled
|
||||||
|
const dnssecIsValid = $derived(dnssecEnabled === true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if dnssecEnabled !== undefined}
|
||||||
|
<div class="card mb-4" id="dns-dnssec">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="text-muted mb-0">
|
||||||
|
<i
|
||||||
|
class="bi"
|
||||||
|
class:bi-shield-check={dnssecIsValid}
|
||||||
|
class:text-success={dnssecIsValid}
|
||||||
|
class:bi-shield-x={!dnssecIsValid}
|
||||||
|
class:text-warning={!dnssecIsValid}
|
||||||
|
></i>
|
||||||
|
DNSSEC
|
||||||
|
</h5>
|
||||||
|
<span class="badge bg-secondary">Security</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text small text-muted mb-3">
|
||||||
|
DNSSEC (DNS Security Extensions) adds cryptographic signatures to DNS records to verify
|
||||||
|
their authenticity and integrity. It protects against DNS spoofing and cache poisoning
|
||||||
|
attacks, ensuring that DNS responses haven't been tampered with.
|
||||||
|
</p>
|
||||||
|
{#if domain}
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Domain:</strong> <code>{domain}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if dnssecIsValid}
|
||||||
|
<div class="alert alert-success mb-0">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
<strong>Enabled:</strong> DNSSEC is properly configured with a valid chain of trust.
|
||||||
|
This provides additional security and authenticity for your domain's DNS records.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
<strong>Not Enabled:</strong> DNSSEC is not configured for this domain. While not
|
||||||
|
required for email delivery, enabling DNSSEC provides additional security by protecting
|
||||||
|
against DNS-based attacks. Consider enabling DNSSEC through your domain registrar or
|
||||||
|
DNS provider.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { theme } from "$lib/stores/theme";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
receivedChain: ReceivedHop[];
|
receivedChain: ReceivedHop[];
|
||||||
|
|
@ -10,42 +9,23 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
<div class="card shadow-sm" id="email-path">
|
<div class="mb-3" id="email-path">
|
||||||
<div
|
<h5>Email Path (Received Chain)</h5>
|
||||||
class="card-header"
|
<div class="list-group">
|
||||||
class:bg-white={$theme === "light"}
|
|
||||||
class:bg-dark={$theme !== "light"}
|
|
||||||
>
|
|
||||||
<h4 class="mb-0">
|
|
||||||
<i class="bi bi-pin-map me-2"></i>
|
|
||||||
Email Path
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
{#each receivedChain as hop, i}
|
{#each receivedChain as hop, i}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h6 class="mb-1">
|
<h6 class="mb-1">
|
||||||
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
|
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
|
||||||
{hop.reverse || "-"}
|
{hop.reverse || '-'} {#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if} → {hop.by || 'Unknown'}
|
||||||
{#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if} → {hop.by ||
|
|
||||||
"Unknown"}
|
|
||||||
</h6>
|
</h6>
|
||||||
<small class="text-muted" title={hop.timestamp}>
|
<small class="text-muted" title={hop.timestamp}>{hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'}</small>
|
||||||
{hop.timestamp
|
|
||||||
? new Intl.DateTimeFormat("default", {
|
|
||||||
dateStyle: "long",
|
|
||||||
timeStyle: "short",
|
|
||||||
}).format(new Date(hop.timestamp))
|
|
||||||
: "-"}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
{#if hop.with || hop.id || hop.from}
|
{#if hop.with || hop.id}
|
||||||
<p class="mb-1 small d-flex gap-3">
|
<p class="mb-1 small d-flex gap-3">
|
||||||
{#if hop.with}
|
{#if hop.with}
|
||||||
<span>
|
<span>
|
||||||
<span class="text-muted">Protocol:</span>
|
<span class="text-muted">Protocol:</span> <code>{hop.with}</code>
|
||||||
<code>{hop.with}</code>
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hop.id}
|
{#if hop.id}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<strong class={getSizeClass(size)} style="color: {getGradeColor(grade)}; font-weight: 700;">
|
<strong
|
||||||
|
class={getSizeClass(size)}
|
||||||
|
style="color: {getGradeColor(grade)}; font-weight: 700;"
|
||||||
|
>
|
||||||
{#if grade}
|
{#if grade}
|
||||||
{grade}
|
{grade}
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
import type { AuthResult, DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
headerScore?: number;
|
headerScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
|
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="header-details">
|
<div class="card shadow-sm" id="header-details">
|
||||||
|
|
@ -38,14 +38,7 @@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h5>Issues</h5>
|
<h5>Issues</h5>
|
||||||
{#each headerAnalysis.issues as issue}
|
{#each headerAnalysis.issues as issue}
|
||||||
<div
|
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
|
||||||
class="alert alert-{issue.severity === 'critical' ||
|
|
||||||
issue.severity === 'high'
|
|
||||||
? 'danger'
|
|
||||||
: issue.severity === 'medium'
|
|
||||||
? 'warning'
|
|
||||||
: 'info'} py-2 px-3 mb-2"
|
|
||||||
>
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<strong>{issue.header}</strong>
|
<strong>{issue.header}</strong>
|
||||||
|
|
@ -65,48 +58,24 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if headerAnalysis.domain_alignment}
|
{#if headerAnalysis.domain_alignment}
|
||||||
{@const spfStrictAligned =
|
{@const spfStrictAligned = headerAnalysis.domain_alignment.from_domain === headerAnalysis.domain_alignment.return_path_domain}
|
||||||
headerAnalysis.domain_alignment.from_domain ===
|
{@const spfRelaxedAligned = headerAnalysis.domain_alignment.from_org_domain === headerAnalysis.domain_alignment.return_path_org_domain}
|
||||||
headerAnalysis.domain_alignment.return_path_domain}
|
|
||||||
{@const spfRelaxedAligned =
|
|
||||||
headerAnalysis.domain_alignment.from_org_domain ===
|
|
||||||
headerAnalysis.domain_alignment.return_path_org_domain}
|
|
||||||
<div class="card mb-3" id="domain-alignment">
|
<div class="card mb-3" id="domain-alignment">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">
|
||||||
<i
|
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle text-info' : 'bi-x-circle-fill text-danger'}"></i>
|
||||||
class="bi {headerAnalysis.domain_alignment.aligned
|
|
||||||
? 'bi-check-circle-fill text-success'
|
|
||||||
: headerAnalysis.domain_alignment.relaxed_aligned
|
|
||||||
? 'bi-check-circle text-info'
|
|
||||||
: 'bi-x-circle-fill text-danger'}"
|
|
||||||
></i>
|
|
||||||
Domain Alignment
|
Domain Alignment
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="card-text small text-muted">
|
<p class="card-text small text-muted">
|
||||||
Domain alignment ensures that the visible "From" domain matches the domain
|
Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path or DKIM signature). Proper alignment is crucial for DMARC compliance, regardless of the policy. It helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. Only one of the following lines needs to pass.
|
||||||
used for authentication (Return-Path or DKIM signature). Proper alignment is
|
|
||||||
crucial for DMARC compliance, regardless of the policy. It helps prevent
|
|
||||||
email spoofing by verifying that the sender domain is consistent across all
|
|
||||||
authentication layers. Only one of the following lines needs to pass.
|
|
||||||
</p>
|
</p>
|
||||||
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
|
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
{#each headerAnalysis.domain_alignment.issues as issue}
|
{#each headerAnalysis.domain_alignment.issues as issue}
|
||||||
<div
|
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
|
||||||
class="alert alert-{headerAnalysis.domain_alignment
|
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
|
||||||
.relaxed_aligned
|
|
||||||
? 'info'
|
|
||||||
: 'warning'} py-2 small mb-2"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi bi-{headerAnalysis.domain_alignment
|
|
||||||
.relaxed_aligned
|
|
||||||
? 'info-circle'
|
|
||||||
: 'exclamation-triangle'} me-1"
|
|
||||||
></i>
|
|
||||||
{issue}
|
{issue}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -115,10 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<div class="list-group-item d-flex ps-0">
|
<div class="list-group-item d-flex ps-0">
|
||||||
<div
|
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
|
||||||
class="d-flex align-items-center justify-content-center"
|
|
||||||
style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;"
|
|
||||||
>
|
|
||||||
SPF
|
SPF
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
|
|
@ -126,17 +92,9 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">Strict Alignment</small>
|
<small class="text-muted">Strict Alignment</small>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="badge" class:bg-success={spfStrictAligned} class:bg-danger={!spfStrictAligned}>
|
||||||
class="badge"
|
<i class="bi {spfStrictAligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||||
class:bg-success={spfStrictAligned}
|
<strong>{spfStrictAligned ? 'Pass' : 'Fail'}</strong>
|
||||||
class:bg-danger={!spfStrictAligned}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi {spfStrictAligned
|
|
||||||
? 'bi-check-circle-fill'
|
|
||||||
: 'bi-x-circle-fill'} me-1"
|
|
||||||
></i>
|
|
||||||
<strong>{spfStrictAligned ? "Pass" : "Fail"}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-1">Exact domain match</div>
|
<div class="small text-muted mt-1">Exact domain match</div>
|
||||||
|
|
@ -144,78 +102,38 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">Relaxed Alignment</small>
|
<small class="text-muted">Relaxed Alignment</small>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="badge" class:bg-success={spfRelaxedAligned} class:bg-danger={!spfRelaxedAligned}>
|
||||||
class="badge"
|
<i class="bi {spfRelaxedAligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||||
class:bg-success={spfRelaxedAligned}
|
<strong>{spfRelaxedAligned ? 'Pass' : 'Fail'}</strong>
|
||||||
class:bg-danger={!spfRelaxedAligned}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi {spfRelaxedAligned
|
|
||||||
? 'bi-check-circle-fill'
|
|
||||||
: 'bi-x-circle-fill'} me-1"
|
|
||||||
></i>
|
|
||||||
<strong>{spfRelaxedAligned ? "Pass" : "Fail"}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Organizational domain match</div>
|
||||||
Organizational domain match
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">From Domain</small>
|
<small class="text-muted">From Domain</small>
|
||||||
<div>
|
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
|
||||||
<code>
|
|
||||||
{headerAnalysis.domain_alignment.from_domain || "-"}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
|
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
|
||||||
Org:
|
|
||||||
<code>
|
|
||||||
{headerAnalysis.domain_alignment.from_org_domain}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">Return-Path Domain</small>
|
<small class="text-muted">Return-Path Domain</small>
|
||||||
<div>
|
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
|
||||||
<code>
|
|
||||||
{headerAnalysis.domain_alignment.return_path_domain ||
|
|
||||||
"-"}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
|
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
|
||||||
Org:
|
|
||||||
<code>
|
|
||||||
{headerAnalysis.domain_alignment
|
|
||||||
.return_path_org_domain}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alignment Information based on DMARC policy -->
|
<!-- Alignment Information based on DMARC policy -->
|
||||||
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
|
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
|
||||||
<div
|
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
|
||||||
class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment ===
|
{#if dmarcRecord.spf_alignment === 'strict'}
|
||||||
'strict'
|
|
||||||
? 'alert-warning'
|
|
||||||
: 'alert-info'}"
|
|
||||||
>
|
|
||||||
{#if dmarcRecord.spf_alignment === "strict"}
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Strict SPF alignment required</strong> — Your DMARC policy
|
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
|
||||||
requires exact domain match. The Return-Path domain must exactly
|
|
||||||
match the From domain for SPF to pass DMARC alignment.
|
|
||||||
{:else}
|
{:else}
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy
|
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass.
|
||||||
allows organizational domain matching. As long as both domains
|
|
||||||
share the same organizational domain (e.g., mail.example.com
|
|
||||||
and example.com), SPF alignment can pass.
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -223,16 +141,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
|
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
|
||||||
{@const dkim_aligned =
|
{@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
|
||||||
dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
|
{@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
|
||||||
{@const dkim_relaxed_aligned =
|
|
||||||
dkim_domain.org_domain ===
|
|
||||||
headerAnalysis.domain_alignment.from_org_domain}
|
|
||||||
<div class="list-group-item d-flex ps-0">
|
<div class="list-group-item d-flex ps-0">
|
||||||
<div
|
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
|
||||||
class="d-flex align-items-center justify-content-center"
|
|
||||||
style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;"
|
|
||||||
>
|
|
||||||
DKIM
|
DKIM
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
|
|
@ -241,72 +153,35 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">Strict Alignment</small>
|
<small class="text-muted">Strict Alignment</small>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="badge" class:bg-success={dkim_aligned} class:bg-danger={!dkim_aligned}>
|
||||||
class="badge"
|
<i class="bi {dkim_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||||
class:bg-success={dkim_aligned}
|
<strong>{dkim_aligned ? 'Pass' : 'Fail'}</strong>
|
||||||
class:bg-danger={!dkim_aligned}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi {dkim_aligned
|
|
||||||
? 'bi-check-circle-fill'
|
|
||||||
: 'bi-x-circle-fill'} me-1"
|
|
||||||
></i>
|
|
||||||
<strong>{dkim_aligned ? "Pass" : "Fail"}</strong
|
|
||||||
>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Exact domain match</div>
|
||||||
Exact domain match
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">Relaxed Alignment</small>
|
<small class="text-muted">Relaxed Alignment</small>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="badge" class:bg-success={dkim_relaxed_aligned} class:bg-danger={!dkim_relaxed_aligned}>
|
||||||
class="badge"
|
<i class="bi {dkim_relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||||
class:bg-success={dkim_relaxed_aligned}
|
<strong>{dkim_relaxed_aligned ? 'Pass' : 'Fail'}</strong>
|
||||||
class:bg-danger={!dkim_relaxed_aligned}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi {dkim_relaxed_aligned
|
|
||||||
? 'bi-check-circle-fill'
|
|
||||||
: 'bi-x-circle-fill'} me-1"
|
|
||||||
></i>
|
|
||||||
<strong
|
|
||||||
>{dkim_relaxed_aligned
|
|
||||||
? "Pass"
|
|
||||||
: "Fail"}</strong
|
|
||||||
>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Organizational domain match</div>
|
||||||
Organizational domain match
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">From Domain</small>
|
<small class="text-muted">From Domain</small>
|
||||||
<div>
|
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
|
||||||
<code
|
|
||||||
>{headerAnalysis.domain_alignment.from_domain ||
|
|
||||||
"-"}</code
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
|
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
|
||||||
Org: <code
|
|
||||||
>{headerAnalysis.domain_alignment
|
|
||||||
.from_org_domain}</code
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<small class="text-muted">Signature Domain</small>
|
<small class="text-muted">Signature Domain</small>
|
||||||
<div><code>{dkim_domain.domain || "-"}</code></div>
|
<div><code>{dkim_domain.domain || '-'}</code></div>
|
||||||
{#if dkim_domain.domain !== dkim_domain.org_domain}
|
{#if dkim_domain.domain !== dkim_domain.org_domain}
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">Org: <code>{dkim_domain.org_domain}</code></div>
|
||||||
Org: <code>{dkim_domain.org_domain}</code>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,25 +189,13 @@
|
||||||
<!-- Alignment Information based on DMARC policy -->
|
<!-- Alignment Information based on DMARC policy -->
|
||||||
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
|
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
|
||||||
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
|
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
|
||||||
<div
|
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
|
||||||
class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment ===
|
{#if dmarcRecord.dkim_alignment === 'strict'}
|
||||||
'strict'
|
|
||||||
? 'alert-warning'
|
|
||||||
: 'alert-info'}"
|
|
||||||
>
|
|
||||||
{#if dmarcRecord.dkim_alignment === "strict"}
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Strict DKIM alignment required</strong> —
|
<strong>Strict DKIM alignment required</strong> — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment.
|
||||||
Your DMARC policy requires exact domain match. The
|
|
||||||
DKIM signature domain must exactly match the From
|
|
||||||
domain for DKIM to pass DMARC alignment.
|
|
||||||
{:else}
|
{:else}
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
<strong>Relaxed DKIM alignment allowed</strong> —
|
<strong>Relaxed DKIM alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass.
|
||||||
Your DMARC policy allows organizational domain matching.
|
|
||||||
As long as both domains share the same organizational
|
|
||||||
domain (e.g., mail.example.com and example.com),
|
|
||||||
DKIM alignment can pass.
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -361,9 +224,9 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each Object.entries(headerAnalysis.headers).sort((a, b) => {
|
{#each Object.entries(headerAnalysis.headers).sort((a, b) => {
|
||||||
const importanceOrder = { required: 0, recommended: 1, optional: 2, newsletter: 3 };
|
const importanceOrder = { 'required': 0, 'recommended': 1, 'optional': 2, 'newsletter': 3 };
|
||||||
const aImportance = importanceOrder[a[1].importance || "optional"];
|
const aImportance = importanceOrder[a[1].importance || 'optional'];
|
||||||
const bImportance = importanceOrder[b[1].importance || "optional"];
|
const bImportance = importanceOrder[b[1].importance || 'optional'];
|
||||||
return aImportance - bImportance;
|
return aImportance - bImportance;
|
||||||
}) as [name, check]}
|
}) as [name, check]}
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -372,39 +235,23 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if check.importance}
|
{#if check.importance}
|
||||||
<small
|
<small class="text-{check.importance === 'required' ? 'danger' : check.importance === 'recommended' ? 'warning' : 'secondary'}">
|
||||||
class="text-{check.importance === 'required'
|
|
||||||
? 'danger'
|
|
||||||
: check.importance === 'recommended'
|
|
||||||
? 'warning'
|
|
||||||
: 'secondary'}"
|
|
||||||
>
|
|
||||||
{check.importance}
|
{check.importance}
|
||||||
</small>
|
</small>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<i
|
<i class="bi {check.present ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'}"></i>
|
||||||
class="bi {check.present
|
|
||||||
? 'bi-check-circle text-success'
|
|
||||||
: 'bi-x-circle text-danger'}"
|
|
||||||
></i>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if check.present && check.valid !== undefined}
|
{#if check.present && check.valid !== undefined}
|
||||||
<i
|
<i class="bi {check.valid ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'}"></i>
|
||||||
class="bi {check.valid
|
|
||||||
? 'bi-check-circle text-success'
|
|
||||||
: 'bi-x-circle text-warning'}"
|
|
||||||
></i>
|
|
||||||
{:else}
|
{:else}
|
||||||
-
|
-
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<small class="text-muted text-truncate" title={check.value}
|
<small class="text-muted text-truncate" title={check.value}>{check.value || '-'}</small>
|
||||||
>{check.value || "-"}</small
|
|
||||||
>
|
|
||||||
{#if check.issues && check.issues.length > 0}
|
{#if check.issues && check.issues.length > 0}
|
||||||
{#each check.issues as issue}
|
{#each check.issues as issue}
|
||||||
<div class="text-warning small">
|
<div class="text-warning small">
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tests: TestSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tests }: Props = $props();
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="table-responsive shadow-sm">
|
|
||||||
<table class="table table-hover mb-0 align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="ps-4" style="width: 80px;">Grade</th>
|
|
||||||
<th style="width: 80px;">Score</th>
|
|
||||||
<th>Domain</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th style="width: 50px;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each tests as test}
|
|
||||||
<tr class="cursor-pointer" onclick={() => goto(`/test/${test.test_id}`)}>
|
|
||||||
<td class="ps-4">
|
|
||||||
<GradeDisplay grade={test.grade} size="small" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary">{test.score}%</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if test.from_domain}
|
|
||||||
<code>{test.from_domain}</code>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">
|
|
||||||
{formatDate(test.created_at)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<i class="bi bi-chevron-right text-muted"></i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-pointer:hover td {
|
|
||||||
background-color: var(--bs-tertiary-bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ClassValue } from "svelte/elements";
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
import type { MxRecord } from "$lib/api/types.gen";
|
import type { MxRecord } from "$lib/api/types.gen";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,6 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
|
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
|
||||||
|
|
||||||
let showDifferent = $state(false);
|
|
||||||
const differentCount = $derived(
|
|
||||||
ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0,
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if ptrRecords && ptrRecords.length > 0}
|
{#if ptrRecords && ptrRecords.length > 0}
|
||||||
|
|
@ -68,31 +63,15 @@
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<strong>Forward Resolution (A/AAAA):</strong>
|
<strong>Forward Resolution (A/AAAA):</strong>
|
||||||
{#each ptrForwardRecords as ip}
|
{#each ptrForwardRecords as ip}
|
||||||
{#if ip === senderIp || !fcrDnsIsValid || showDifferent}
|
<div class="d-flex gap-2 align-items-center mt-1">
|
||||||
<div class="d-flex gap-2 align-items-center mt-1">
|
{#if senderIp && ip === senderIp}
|
||||||
{#if senderIp && ip === senderIp}
|
<span class="badge bg-success">Match</span>
|
||||||
<span class="badge bg-success">Match</span>
|
{:else}
|
||||||
{:else}
|
<span class="badge bg-warning">Different</span>
|
||||||
<span class="badge bg-secondary">Different</span>
|
{/if}
|
||||||
{/if}
|
<code>{ip}</code>
|
||||||
<code>{ip}</code>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{#if fcrDnsIsValid && differentCount > 0}
|
|
||||||
<div class="mt-1">
|
|
||||||
<button
|
|
||||||
class="btn btn-link btn-sm p-0 text-muted"
|
|
||||||
onclick={() => (showDifferent = !showDifferent)}
|
|
||||||
>
|
|
||||||
{#if showDifferent}
|
|
||||||
Hide other IPs
|
|
||||||
{:else}
|
|
||||||
Show {differentCount} other IP{differentCount > 1 ? 's' : ''} (not the sender)
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if fcrDnsIsValid}
|
{#if fcrDnsIsValid}
|
||||||
<div class="alert alert-success mb-0 mt-2">
|
<div class="alert alert-success mb-0 mt-2">
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { RspamdResult } from "$lib/api/types.gen";
|
|
||||||
import { getScoreColorClass } from "$lib/score";
|
|
||||||
import { theme } from "$lib/stores/theme";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rspamd: RspamdResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { rspamd }: Props = $props();
|
|
||||||
|
|
||||||
// Derive effective action from score vs known rspamd default thresholds.
|
|
||||||
// The action header is unreliable in milter setups (always "no action").
|
|
||||||
const RSPAMD_GREYLIST_THRESHOLD = 4;
|
|
||||||
const RSPAMD_ADD_HEADER_THRESHOLD = 6;
|
|
||||||
|
|
||||||
const effectiveAction = $derived.by(() => {
|
|
||||||
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
|
|
||||||
if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" };
|
|
||||||
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
|
|
||||||
return { label: "Add header", cls: "bg-warning text-dark" };
|
|
||||||
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
|
||||||
return { label: "Greylist", cls: "bg-warning text-dark" };
|
|
||||||
return { label: "No action", cls: "bg-success" };
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card shadow-sm" id="rspamd-details">
|
|
||||||
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
|
||||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
<i class="bi bi-bug me-2"></i>
|
|
||||||
rspamd Analysis
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{#if rspamd.deliverability_score !== undefined}
|
|
||||||
<span class="badge bg-{getScoreColorClass(rspamd.deliverability_score)}">
|
|
||||||
{rspamd.deliverability_score}%
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if rspamd.deliverability_grade !== undefined}
|
|
||||||
<GradeDisplay grade={rspamd.deliverability_grade} size="small" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<strong>Score:</strong>
|
|
||||||
<span class={rspamd.is_spam ? "text-danger" : "text-success"}>
|
|
||||||
{rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<strong>Classified as:</strong>
|
|
||||||
<span class="badge {rspamd.is_spam ? 'bg-danger' : 'bg-success'} ms-2">
|
|
||||||
{rspamd.is_spam ? "SPAM" : "HAM"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<strong>Action:</strong>
|
|
||||||
<span class="badge {effectiveAction.cls} ms-2">
|
|
||||||
{effectiveAction.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0}
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="table-responsive mt-2">
|
|
||||||
<table class="table table-sm table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Symbol</th>
|
|
||||||
<th class="text-end">Score</th>
|
|
||||||
<th>Description</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>
|
|
||||||
<span class="font-monospace">{symbolName}</span>
|
|
||||||
{#if symbol.params}
|
|
||||||
<small class="d-block text-muted">
|
|
||||||
{symbol.params}
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span
|
|
||||||
class={symbol.score > 0
|
|
||||||
? "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.description ?? ""}</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">
|
<script lang="ts">
|
||||||
import type { ScoreSummary } from "$lib/api/types.gen";
|
import type { ScoreSummary } from "$lib/api/types.gen";
|
||||||
import { theme } from "$lib/stores/theme";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
import { theme } from "$lib/stores/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
grade: string;
|
grade: string;
|
||||||
|
|
@ -58,10 +58,13 @@
|
||||||
<a href="#dns-details" class="text-decoration-none">
|
<a href="#dns-details" class="text-decoration-none">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === 'light'}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== 'light'}
|
||||||
>
|
>
|
||||||
<GradeDisplay grade={summary.dns_grade} score={summary.dns_score} />
|
<GradeDisplay
|
||||||
|
grade={summary.dns_grade}
|
||||||
|
score={summary.dns_score}
|
||||||
|
/>
|
||||||
<small class="text-muted d-block">DNS</small>
|
<small class="text-muted d-block">DNS</small>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -70,8 +73,8 @@
|
||||||
<a href="#authentication-details" class="text-decoration-none">
|
<a href="#authentication-details" class="text-decoration-none">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === 'light'}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== 'light'}
|
||||||
>
|
>
|
||||||
<GradeDisplay
|
<GradeDisplay
|
||||||
grade={summary.authentication_grade}
|
grade={summary.authentication_grade}
|
||||||
|
|
@ -85,8 +88,8 @@
|
||||||
<a href="#rbl-details" class="text-decoration-none">
|
<a href="#rbl-details" class="text-decoration-none">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === 'light'}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== 'light'}
|
||||||
>
|
>
|
||||||
<GradeDisplay
|
<GradeDisplay
|
||||||
grade={summary.blacklist_grade}
|
grade={summary.blacklist_grade}
|
||||||
|
|
@ -100,8 +103,8 @@
|
||||||
<a href="#header-details" class="text-decoration-none">
|
<a href="#header-details" class="text-decoration-none">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === 'light'}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== 'light'}
|
||||||
>
|
>
|
||||||
<GradeDisplay
|
<GradeDisplay
|
||||||
grade={summary.header_grade}
|
grade={summary.header_grade}
|
||||||
|
|
@ -115,10 +118,13 @@
|
||||||
<a href="#spam-details" class="text-decoration-none">
|
<a href="#spam-details" class="text-decoration-none">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === 'light'}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== 'light'}
|
||||||
>
|
>
|
||||||
<GradeDisplay grade={summary.spam_grade} score={summary.spam_score} />
|
<GradeDisplay
|
||||||
|
grade={summary.spam_grade}
|
||||||
|
score={summary.spam_score}
|
||||||
|
/>
|
||||||
<small class="text-muted d-block">Spam Score</small>
|
<small class="text-muted d-block">Spam Score</small>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -127,8 +133,8 @@
|
||||||
<a href="#content-details" class="text-decoration-none">
|
<a href="#content-details" class="text-decoration-none">
|
||||||
<div
|
<div
|
||||||
class="p-2 rounded text-center summary-card"
|
class="p-2 rounded text-center summary-card"
|
||||||
class:bg-light={$theme === "light"}
|
class:bg-light={$theme === 'light'}
|
||||||
class:bg-secondary={$theme !== "light"}
|
class:bg-secondary={$theme !== 'light'}
|
||||||
>
|
>
|
||||||
<GradeDisplay
|
<GradeDisplay
|
||||||
grade={summary.content_grade}
|
grade={summary.content_grade}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spamassassin: SpamAssassinResult;
|
spamassassin: SpamAssassinResult;
|
||||||
|
spamGrade?: string;
|
||||||
|
spamScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { spamassassin }: Props = $props();
|
let { spamassassin, spamGrade, spamScore }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="spam-details">
|
<div class="card shadow-sm" id="spam-details">
|
||||||
|
|
@ -19,13 +21,13 @@
|
||||||
SpamAssassin Analysis
|
SpamAssassin Analysis
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{#if spamassassin.deliverability_score !== undefined}
|
{#if spamScore !== undefined}
|
||||||
<span class="badge bg-{getScoreColorClass(spamassassin.deliverability_score)}">
|
<span class="badge bg-{getScoreColorClass(spamScore)}">
|
||||||
{spamassassin.deliverability_score}%
|
{spamScore}%
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if spamassassin.deliverability_grade !== undefined}
|
{#if spamGrade !== undefined}
|
||||||
<GradeDisplay grade={spamassassin.deliverability_grade} size="small" />
|
<GradeDisplay grade={spamGrade} size="small" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
@ -59,26 +61,14 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each Object.entries(spamassassin.test_details) as [testName, detail]}
|
{#each Object.entries(spamassassin.test_details) as [testName, detail]}
|
||||||
<tr
|
<tr class={detail.score > 0 ? 'table-warning' : detail.score < 0 ? 'table-success' : ''}>
|
||||||
class={detail.score > 0
|
|
||||||
? "table-warning"
|
|
||||||
: detail.score < 0
|
|
||||||
? "table-success"
|
|
||||||
: ""}
|
|
||||||
>
|
|
||||||
<td class="font-monospace">{testName}</td>
|
<td class="font-monospace">{testName}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<span
|
<span class={detail.score > 0 ? 'text-danger fw-bold' : detail.score < 0 ? 'text-success fw-bold' : 'text-muted'}>
|
||||||
class={detail.score > 0
|
{detail.score > 0 ? '+' : ''}{detail.score.toFixed(1)}
|
||||||
? "text-danger fw-bold"
|
|
||||||
: detail.score < 0
|
|
||||||
? "text-success fw-bold"
|
|
||||||
: "text-muted"}
|
|
||||||
>
|
|
||||||
{detail.score > 0 ? "+" : ""}{detail.score.toFixed(1)}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">{detail.description || ""}</td>
|
<td class="small">{detail.description || ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -90,11 +80,7 @@
|
||||||
<strong>Tests Triggered:</strong>
|
<strong>Tests Triggered:</strong>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{#each spamassassin.tests as test}
|
{#each spamassassin.tests as test}
|
||||||
<span
|
<span class="badge {$theme === 'light' ? 'bg-light text-dark' : 'bg-secondary'} me-1 mb-1">{test}</span>
|
||||||
class="badge {$theme === 'light'
|
|
||||||
? 'bg-light text-dark'
|
|
||||||
: 'bg-secondary'} me-1 mb-1">{test}</span
|
|
||||||
>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,10 +89,7 @@
|
||||||
{#if spamassassin.report}
|
{#if spamassassin.report}
|
||||||
<details class="mt-3">
|
<details class="mt-3">
|
||||||
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
||||||
<pre
|
<pre class="mt-2 small {$theme === 'light' ? 'bg-light' : 'bg-secondary'} p-3 rounded">{spamassassin.report}</pre>
|
||||||
class="mt-2 small {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} p-3 rounded">{spamassassin.report}</pre>
|
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
// Check if DMARC has strict policy (quarantine or reject)
|
// Check if DMARC has strict policy (quarantine or reject)
|
||||||
const dmarcStrict = $derived(
|
const dmarcStrict = $derived(
|
||||||
dmarcRecord?.valid &&
|
dmarcRecord?.valid &&
|
||||||
dmarcRecord?.policy &&
|
dmarcRecord?.policy &&
|
||||||
(dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject"),
|
(dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute overall validity
|
// Compute overall validity
|
||||||
|
|
@ -43,11 +43,7 @@
|
||||||
<span class="badge bg-secondary">SPF</span>
|
<span class="badge bg-secondary">SPF</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="card-text small text-muted mb-0">
|
<p class="card-text small text-muted mb-0">SPF specifies which mail servers are authorized to send emails on behalf of your domain. Receiving servers check the sender's IP address against your SPF record to prevent email spoofing.</p>
|
||||||
SPF specifies which mail servers are authorized to send emails on behalf of your
|
|
||||||
domain. Receiving servers check the sender's IP address against your SPF record to
|
|
||||||
prevent email spoofing.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
{#each spfRecords as spf, index}
|
{#each spfRecords as spf, index}
|
||||||
|
|
@ -80,31 +76,18 @@
|
||||||
{:else if spf.all_qualifier === "?"}
|
{:else if spf.all_qualifier === "?"}
|
||||||
<span class="badge bg-warning">Neutral (?all)</span>
|
<span class="badge bg-warning">Neutral (?all)</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes("redirect="))}
|
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))}
|
||||||
<div
|
<div class="alert small mt-2" class:alert-warning={spf.all_qualifier !== '-'} class:alert-success={spf.all_qualifier === '-'}>
|
||||||
class="alert small mt-2"
|
{#if spf.all_qualifier === '-'}
|
||||||
class:alert-warning={spf.all_qualifier !== "-"}
|
All unauthorized servers will be rejected. This is the recommended strict policy.
|
||||||
class:alert-success={spf.all_qualifier === "-"}
|
|
||||||
>
|
|
||||||
{#if spf.all_qualifier === "-"}
|
|
||||||
All unauthorized servers will be rejected. This is the
|
|
||||||
recommended strict policy.
|
|
||||||
{:else if dmarcStrict}
|
{:else if dmarcStrict}
|
||||||
While your DMARC {dmarcRecord?.policy} policy provides some protection,
|
While your DMARC {dmarcRecord?.policy} policy provides some protection, consider using <code>-all</code> for better security with some old mailbox providers.
|
||||||
consider using <code>-all</code> for better security with some
|
{:else if spf.all_qualifier === '~'}
|
||||||
old mailbox providers.
|
Unauthorized servers will softfail. Consider using <code>-all</code> for stricter policy, though this rarely affects legitimate email deliverability.
|
||||||
{:else if spf.all_qualifier === "~"}
|
{:else if spf.all_qualifier === '+'}
|
||||||
Unauthorized servers will softfail. Consider using <code
|
All servers are allowed to send email. This severely weakens email authentication. Use <code>-all</code> for strict policy.
|
||||||
>-all</code
|
{:else if spf.all_qualifier === '?'}
|
||||||
> for stricter policy, though this rarely affects legitimate
|
No statement about unauthorized servers. Use <code>-all</code> for strict policy to prevent spoofing.
|
||||||
email deliverability.
|
|
||||||
{:else if spf.all_qualifier === "+"}
|
|
||||||
All servers are allowed to send email. This severely weakens
|
|
||||||
email authentication. Use <code>-all</code> for strict policy.
|
|
||||||
{:else if spf.all_qualifier === "?"}
|
|
||||||
No statement about unauthorized servers. Use <code
|
|
||||||
>-all</code
|
|
||||||
> for strict policy to prevent spoofing.
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -112,16 +95,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if spf.record}
|
{#if spf.record}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<strong>Record:</strong><br />
|
<strong>Record:</strong><br>
|
||||||
<code class="d-block mt-1 text-break">{spf.record}</code>
|
<code class="d-block mt-1 text-break">{spf.record}</code>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if spf.error}
|
{#if spf.error}
|
||||||
<div class="alert alert-{spf.valid ? 'warning' : 'danger'} mb-0 mt-2">
|
<div class="alert alert-{spf.valid ? 'warning' : 'danger'} mb-0 mt-2">
|
||||||
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"
|
<i class="bi bi-{spf.valid ? 'exclamation-triangle' : 'x-circle'} me-1"></i>
|
||||||
></i>
|
<strong>{spf.valid ? 'Warning:' : 'Error:'}</strong> {spf.error}
|
||||||
<strong>{spf.valid ? "Warning:" : "Error:"}</strong>
|
|
||||||
{spf.error}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,32 +25,16 @@
|
||||||
|
|
||||||
// Email sender information
|
// Email sender information
|
||||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||||
const hasDkim =
|
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
|
||||||
report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0;
|
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||||
const dkimPassed =
|
|
||||||
report.authentication?.dkim &&
|
|
||||||
report.authentication?.dkim.length > 0 &&
|
|
||||||
report.authentication?.dkim?.some((d) => d.result === "pass");
|
|
||||||
|
|
||||||
segments.push({ text: "Received a " });
|
segments.push({ text: "Received a " });
|
||||||
segments.push({
|
segments.push({
|
||||||
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
|
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
||||||
highlight: {
|
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
||||||
color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
|
link: "#authentication-dkim",
|
||||||
bold: true,
|
|
||||||
},
|
|
||||||
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
|
|
||||||
});
|
});
|
||||||
segments.push({ text: " email" });
|
segments.push({ text: " email from " });
|
||||||
if (hasDkim && !dkimPassed) {
|
|
||||||
segments.push({ text: " with " });
|
|
||||||
segments.push({
|
|
||||||
text: "an invalid signature",
|
|
||||||
highlight: { color: "danger", bold: true },
|
|
||||||
link: "#authentication-dkim",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
segments.push({ text: " from " });
|
|
||||||
segments.push({
|
segments.push({
|
||||||
text: mailFrom,
|
text: mailFrom,
|
||||||
highlight: { emphasis: true },
|
highlight: { emphasis: true },
|
||||||
|
|
@ -129,7 +113,7 @@
|
||||||
} else if (spfResult === "temperror" || spfResult === "permerror") {
|
} else if (spfResult === "temperror" || spfResult === "permerror") {
|
||||||
segments.push({
|
segments.push({
|
||||||
text: "encountered an error",
|
text: "encountered an error",
|
||||||
highlight: { color: "danger", bold: true },
|
highlight: { color: "warning", bold: true },
|
||||||
link: "#authentication-spf",
|
link: "#authentication-spf",
|
||||||
});
|
});
|
||||||
segments.push({ text: ", check your SPF record configuration" });
|
segments.push({ text: ", check your SPF record configuration" });
|
||||||
|
|
@ -334,9 +318,7 @@
|
||||||
// BIMI
|
// BIMI
|
||||||
const bimiResult = report.authentication?.bimi;
|
const bimiResult = report.authentication?.bimi;
|
||||||
if (
|
if (
|
||||||
dmarcRecord &&
|
(dmarcRecord && dmarcRecord.valid && dmarcRecord.policy != "none") &&
|
||||||
dmarcRecord.valid &&
|
|
||||||
dmarcRecord.policy != "none" &&
|
|
||||||
(!bimiResult || bimiResult.result !== "skipped")
|
(!bimiResult || bimiResult.result !== "skipped")
|
||||||
) {
|
) {
|
||||||
const bimiRecord = report.dns_results?.bimi_record;
|
const bimiRecord = report.dns_results?.bimi_record;
|
||||||
|
|
@ -347,7 +329,7 @@
|
||||||
highlight: { color: "good", bold: true },
|
highlight: { color: "good", bold: true },
|
||||||
link: "#dns-bimi",
|
link: "#dns-bimi",
|
||||||
});
|
});
|
||||||
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
|
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
|
||||||
segments.push({ text: " declined to participate" });
|
segments.push({ text: " declined to participate" });
|
||||||
} else if (bimiResult?.result === "fail") {
|
} else if (bimiResult?.result === "fail") {
|
||||||
segments.push({ text: " but " });
|
segments.push({ text: " but " });
|
||||||
|
|
@ -438,17 +420,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-click unsubscribe check
|
|
||||||
const unsubscribeMethods = report.content_analysis?.unsubscribe_methods;
|
|
||||||
if (unsubscribeMethods && unsubscribeMethods.length > 0 && !unsubscribeMethods.includes("one-click")) {
|
|
||||||
segments.push({ text: ". This email could benefit from " });
|
|
||||||
segments.push({
|
|
||||||
text: "one-click unsubscribe",
|
|
||||||
highlight: { color: "warning", bold: true },
|
|
||||||
link: "#content-details",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content/spam assessment
|
// Content/spam assessment
|
||||||
const spamAssassin = report.spamassassin;
|
const spamAssassin = report.spamassassin;
|
||||||
const contentScore = report.summary?.content_score || 0;
|
const contentScore = report.summary?.content_score || 0;
|
||||||
|
|
@ -552,39 +523,19 @@
|
||||||
{#if segment.link}
|
{#if segment.link}
|
||||||
<a
|
<a
|
||||||
href={segment.link}
|
href={segment.link}
|
||||||
class="summary-link {segment.highlight
|
class="summary-link {segment.highlight ? getColorClass(segment.highlight.color) : ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
|
||||||
? getColorClass(segment.highlight.color)
|
|
||||||
: ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight
|
|
||||||
?.emphasis
|
|
||||||
? 'fst-italic'
|
|
||||||
: ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
|
|
||||||
>
|
>
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</a>
|
</a>
|
||||||
{:else if segment.highlight}
|
{:else if segment.highlight}
|
||||||
<span
|
<span class="{getColorClass(segment.highlight.color)} {segment.highlight.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}">
|
||||||
class="{getColorClass(segment.highlight.color)} {segment.highlight.bold
|
|
||||||
? 'highlighted'
|
|
||||||
: ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment
|
|
||||||
.highlight?.monospace
|
|
||||||
? 'font-monospace'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
{segment.text}
|
{segment.text}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
Overall, your email received a grade <GradeDisplay
|
Overall, your email received a grade <GradeDisplay grade={report.grade} score={report.score} size="inline" />{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}: you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}: you could have delivery issues with common providers.{:else if report.grade == "F"}: it will most likely be rejected by most providers.{:else}!{/if} Check the details below 🔽
|
||||||
grade={report.grade}
|
|
||||||
score={report.score}
|
|
||||||
size="inline"
|
|
||||||
/>{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}:
|
|
||||||
you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}:
|
|
||||||
you could have delivery issues with common providers.{:else if report.grade == "F"}:
|
|
||||||
it will most likely be rejected by most providers.{:else}!{/if} Check the details below
|
|
||||||
🔽
|
|
||||||
</p>
|
</p>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
|
||||||
import { theme } from "$lib/stores/theme";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
whitelists: Record<string, BlacklistCheck[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { whitelists }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card shadow-sm" id="dnswl-details">
|
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
|
||||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
<i class="bi bi-shield-check me-2"></i>
|
|
||||||
Whitelist Checks
|
|
||||||
</span>
|
|
||||||
<span class="badge bg-info text-white">Informational</span>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small mb-3">
|
|
||||||
DNS whitelists identify trusted senders. Being listed here is a positive signal, but has
|
|
||||||
no impact on the overall score.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
|
||||||
{#each Object.entries(whitelists) as [ip, checks]}
|
|
||||||
<div class="col mb-3">
|
|
||||||
<h5 class="text-muted">
|
|
||||||
<i class="bi bi-hdd-network me-1"></i>
|
|
||||||
{ip}
|
|
||||||
</h5>
|
|
||||||
<table class="table table-sm table-striped table-hover mb-0">
|
|
||||||
<tbody>
|
|
||||||
{#each checks as check}
|
|
||||||
<tr>
|
|
||||||
<td title={check.response || "-"}>
|
|
||||||
<span
|
|
||||||
class="badge"
|
|
||||||
class:bg-success={check.listed}
|
|
||||||
class:bg-dark={check.error}
|
|
||||||
class:bg-secondary={!check.listed && !check.error}
|
|
||||||
>
|
|
||||||
{check.error
|
|
||||||
? "Error"
|
|
||||||
: check.listed
|
|
||||||
? "Listed"
|
|
||||||
: "Not listed"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td><code>{check.rbl}</code></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
// Component exports
|
// Component exports
|
||||||
|
export { default as FeatureCard } from "./FeatureCard.svelte";
|
||||||
|
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
|
||||||
|
export { default as ScoreCard } from "./ScoreCard.svelte";
|
||||||
|
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||||
|
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||||
|
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
|
||||||
|
export { default as PendingState } from "./PendingState.svelte";
|
||||||
export { default as AuthenticationCard } from "./AuthenticationCard.svelte";
|
export { default as AuthenticationCard } from "./AuthenticationCard.svelte";
|
||||||
export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte";
|
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
|
||||||
export { default as BlacklistCard } from "./BlacklistCard.svelte";
|
export { default as BlacklistCard } from "./BlacklistCard.svelte";
|
||||||
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
|
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
|
||||||
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
|
|
||||||
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
|
|
||||||
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
|
|
||||||
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
|
|
||||||
export { default as EmailPathCard } from "./EmailPathCard.svelte";
|
|
||||||
export { default as ErrorDisplay } from "./ErrorDisplay.svelte";
|
|
||||||
export { default as FeatureCard } from "./FeatureCard.svelte";
|
|
||||||
export { default as GradeDisplay } from "./GradeDisplay.svelte";
|
|
||||||
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
|
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";
|
||||||
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
|
|
||||||
export { default as Logo } from "./Logo.svelte";
|
|
||||||
export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte";
|
|
||||||
export { default as PendingState } from "./PendingState.svelte";
|
|
||||||
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
|
|
||||||
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
|
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
|
||||||
export { default as ScoreCard } from "./ScoreCard.svelte";
|
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
|
||||||
export { default as RspamdCard } from "./RspamdCard.svelte";
|
|
||||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
|
||||||
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
|
||||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
|
||||||
export { default as HistoryTable } from "./HistoryTable.svelte";
|
|
||||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||||
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
export { default as ErrorDisplay } from "./ErrorDisplay.svelte";
|
||||||
|
export { default as GradeDisplay } from "./GradeDisplay.svelte";
|
||||||
|
export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte";
|
||||||
|
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
||||||
|
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
|
||||||
|
export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte";
|
||||||
|
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
|
||||||
|
export { default as Logo } from "./Logo.svelte";
|
||||||
|
export { default as EmailPathCard } from "./EmailPathCard.svelte";
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,6 @@ import { writable } from "svelte/store";
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
report_retention?: number;
|
report_retention?: number;
|
||||||
survey_url?: string;
|
survey_url?: string;
|
||||||
custom_logo_url?: string;
|
|
||||||
rbls?: string[];
|
|
||||||
test_list_enabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: AppConfig = {
|
const defaultConfig: AppConfig = {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,11 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
const getInitialTheme = () => {
|
const getInitialTheme = () => {
|
||||||
if (!browser) return "light";
|
if (!browser) return "light";
|
||||||
|
|
||||||
const stored = localStorage.getItem("theme");
|
const stored = localStorage.getItem("theme");
|
||||||
if (stored === "light" || stored === "dark") return stored;
|
if (stored) return stored;
|
||||||
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/stores";
|
||||||
import { ErrorDisplay } from "$lib/components";
|
import { ErrorDisplay } from "$lib/components";
|
||||||
|
|
||||||
let status = $derived(page.status);
|
let status = $derived($page.status);
|
||||||
let message = $derived(page.error?.message || "An unexpected error occurred");
|
let message = $derived($page.error?.message || "An unexpected error occurred");
|
||||||
|
|
||||||
function getErrorTitle(status: number): string {
|
function getErrorTitle(status: number): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
|
|
||||||
import favicon from "$lib/assets/favicon.svg";
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
import Logo from "$lib/components/Logo.svelte";
|
import Logo from "$lib/components/Logo.svelte";
|
||||||
import { appConfig } from "$lib/stores/config";
|
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
|
@ -26,31 +25,17 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-vh-100 d-flex flex-column">
|
<div class="min-vh-100 d-flex flex-column">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light shadow-sm">
|
<nav class="navbar navbar-expand-lg navbar-light shadow-sm">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand fw-bold" href="/">
|
<a class="navbar-brand fw-bold" href="/">
|
||||||
{#if $appConfig.custom_logo_url}
|
<i class="bi bi-envelope-check me-2"></i>
|
||||||
<img src={$appConfig.custom_logo_url} alt="Logo" style="height: 25px;" />
|
<Logo color={$theme === "light" ? "black" : "white"} />
|
||||||
{:else}
|
|
||||||
<i class="bi bi-envelope-check me-2"></i>
|
|
||||||
<Logo color={$theme === "light" ? "black" : "white"} />
|
|
||||||
{/if}
|
|
||||||
</a>
|
</a>
|
||||||
{#if $appConfig.test_list_enabled}
|
<div>
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/history/">
|
|
||||||
<i class="bi bi-clock-history me-1"></i>
|
|
||||||
History
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="d-none d-md-inline navbar-text text-primary small">
|
<span class="d-none d-md-inline navbar-text text-primary small">
|
||||||
Open-Source Email Deliverability Tester
|
Open-Source Email Deliverability Tester
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -70,26 +55,7 @@
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer
|
<footer class="pt-3 pb-2 bg-dark text-light">
|
||||||
id="footer-classic"
|
|
||||||
class="px-4 px-md-5 py-2 bg-tertiary d-flex justify-content-between"
|
|
||||||
>
|
|
||||||
<a href="https://happydeliver.org/" target="_blank">Powered by happyDeliver</a>
|
|
||||||
<ul class="d-flex footer-nav">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://git.happydomain.org/happydeliver/-/blob/master/api/openapi.yaml?ref_type=heads"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
API
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li><a href="https://git.happydomain.org/happydeliver" target="_blank">Git</a></li>
|
|
||||||
<li><a href="https://feedback.happydeliver.org/" target="_blank">Feedback</a></li>
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<footer id="footer-happydomain" class="d-none pt-3 pb-2 bg-dark text-light">
|
|
||||||
<div class="container mb-4">
|
<div class="container mb-4">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -178,27 +144,6 @@
|
||||||
border-top: 3px solid #9332bb;
|
border-top: 3px solid #9332bb;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-nav {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-nav li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-nav li:not(:last-child)::after {
|
|
||||||
content: "|";
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
.footer-links {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -210,6 +155,7 @@
|
||||||
|
|
||||||
.footer-links a {
|
.footer-links a {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue