Compare commits
24 commits
master
...
renovate/s
| Author | SHA1 | Date | |
|---|---|---|---|
| 84cec363e2 | |||
| 4ed08c5fa9 | |||
| b47676f234 | |||
| f1b9ac1e27 | |||
| fedb80f7d4 | |||
| 01569d1b21 | |||
| 78c070cdcf | |||
| 74866d210c | |||
| 35ff54b2e1 | |||
| fd5054f886 | |||
| 50b72bb492 | |||
| 19d9c6c859 | |||
| 37babf1ad6 | |||
| aa0d571d77 | |||
| 6dce13cf51 | |||
| d178c1c440 | |||
| f327733009 | |||
| 9d80e5e401 | |||
| 7049e2d012 | |||
| ef433bfbe5 | |||
| 54ad0e1151 | |||
| b96af8349d | |||
| 4f0d0a7e83 | |||
| 7161eaaafd |
132 changed files with 7631 additions and 16987 deletions
10
.drone.yml
10
.drone.yml
|
|
@ -9,7 +9,7 @@ platform:
|
|||
|
||||
steps:
|
||||
- name: frontend
|
||||
image: node:24-alpine
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- cd web
|
||||
- npm install --network-timeout=100000
|
||||
|
|
@ -21,7 +21,7 @@ steps:
|
|||
commands:
|
||||
- apk add --no-cache git
|
||||
- go generate ./...
|
||||
- go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
|
||||
- go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
|
||||
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
|
|
@ -35,7 +35,7 @@ steps:
|
|||
commands:
|
||||
- apk add --no-cache git
|
||||
- go generate ./...
|
||||
- go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
|
||||
- go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
|
||||
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
|
|
@ -47,7 +47,7 @@ steps:
|
|||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
|
||||
- go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: darwin
|
||||
|
|
@ -61,7 +61,7 @@ steps:
|
|||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
|
||||
- go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: darwin
|
||||
|
|
|
|||
125
Dockerfile
125
Dockerfile
|
|
@ -1,6 +1,6 @@
|
|||
# Multi-stage Dockerfile for happyDeliver with integrated MTA
|
||||
# Stage 1: Build the Svelte application
|
||||
FROM node:24-alpine AS nodebuild
|
||||
FROM node:22-alpine AS nodebuild
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -31,97 +31,19 @@ COPY --from=nodebuild /build/web/build/ ./web/build/
|
|||
RUN go generate ./... && \
|
||||
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
|
||||
|
||||
# Stage 3: Prepare perl and spamass-milt
|
||||
FROM alpine:3 AS pl
|
||||
|
||||
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
|
||||
apk add --no-cache \
|
||||
build-base \
|
||||
libmilter-dev \
|
||||
musl-obstack-dev \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
perl-app-cpanminus \
|
||||
perl-alien-libxml2 \
|
||||
perl-class-load-xs \
|
||||
perl-cpanel-json-xs \
|
||||
perl-crypt-openssl-rsa \
|
||||
perl-crypt-openssl-random \
|
||||
perl-crypt-openssl-verify \
|
||||
perl-crypt-openssl-x509 \
|
||||
perl-dbd-sqlite \
|
||||
perl-dbi \
|
||||
perl-email-address-xs \
|
||||
perl-json-xs \
|
||||
perl-list-moreutils \
|
||||
perl-moose \
|
||||
perl-net-idn-encode@edge \
|
||||
perl-net-ssleay \
|
||||
perl-netaddr-ip \
|
||||
perl-package-stash \
|
||||
perl-params-util \
|
||||
perl-params-validate \
|
||||
perl-proc-processtable \
|
||||
perl-sereal-decoder \
|
||||
perl-sereal-encoder \
|
||||
perl-socket6 \
|
||||
perl-sub-identify \
|
||||
perl-variable-magic \
|
||||
perl-xml-libxml \
|
||||
perl-dev \
|
||||
spamassassin-client \
|
||||
zlib-dev \
|
||||
&& \
|
||||
ln -s /usr/bin/ld /bin/ld
|
||||
|
||||
RUN cpanm --notest Mail::SPF && \
|
||||
cpanm --notest Mail::Milter::Authentication
|
||||
|
||||
RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \
|
||||
tar xzf spamass-milter-0.4.0.tar.gz && \
|
||||
cd spamass-milter-0.4.0 && \
|
||||
./configure && make install
|
||||
|
||||
# Stage 4: Runtime image with Postfix and all filters
|
||||
# Stage 3: Runtime image with Postfix and all filters
|
||||
FROM alpine:3
|
||||
|
||||
# Install all required packages
|
||||
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
|
||||
apk add --no-cache \
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
libmilter \
|
||||
openssl \
|
||||
perl \
|
||||
perl-alien-libxml2 \
|
||||
perl-class-load-xs \
|
||||
perl-cpanel-json-xs \
|
||||
perl-crypt-openssl-rsa \
|
||||
perl-crypt-openssl-random \
|
||||
perl-crypt-openssl-verify \
|
||||
perl-crypt-openssl-x509 \
|
||||
perl-dbd-sqlite \
|
||||
perl-dbi \
|
||||
perl-email-address-xs \
|
||||
perl-json-xs \
|
||||
perl-list-moreutils \
|
||||
perl-moose \
|
||||
perl-net-idn-encode@edge \
|
||||
perl-net-ssleay \
|
||||
perl-netaddr-ip \
|
||||
perl-package-stash \
|
||||
perl-params-util \
|
||||
perl-params-validate \
|
||||
perl-proc-processtable \
|
||||
perl-sereal-decoder \
|
||||
perl-sereal-encoder \
|
||||
perl-socket6 \
|
||||
perl-sub-identify \
|
||||
perl-variable-magic \
|
||||
perl-xml-libxml \
|
||||
opendkim \
|
||||
opendkim-utils \
|
||||
opendmarc \
|
||||
postfix \
|
||||
postfix-pcre \
|
||||
rspamd \
|
||||
postfix-policyd-spf-perl \
|
||||
spamassassin \
|
||||
spamassassin-client \
|
||||
supervisor \
|
||||
|
|
@ -129,8 +51,9 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
|||
tzdata \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Copy Mail::Milter::Authentication and its dependancies
|
||||
COPY --from=pl /usr/local/ /usr/local/
|
||||
# Get test-only version of postfix-policyd-spf-perl
|
||||
ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
|
||||
RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl
|
||||
|
||||
# Create happydeliver user and group
|
||||
RUN addgroup -g 1000 happydeliver && \
|
||||
|
|
@ -140,15 +63,12 @@ RUN addgroup -g 1000 happydeliver && \
|
|||
RUN mkdir -p /etc/happydeliver \
|
||||
/var/lib/happydeliver \
|
||||
/var/log/happydeliver \
|
||||
/var/cache/authentication_milter \
|
||||
/var/lib/authentication_milter \
|
||||
/var/spool/postfix/authentication_milter \
|
||||
/var/spool/postfix/spamassassin \
|
||||
/var/spool/postfix/rspamd \
|
||||
/var/spool/postfix/opendkim \
|
||||
/var/spool/postfix/opendmarc \
|
||||
/etc/opendkim/keys \
|
||||
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
||||
&& 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
|
||||
&& chown -R opendkim:postfix /var/spool/postfix/opendkim \
|
||||
&& chown -R opendmarc:postfix /var/spool/postfix/opendmarc
|
||||
|
||||
# Copy the built application
|
||||
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||
|
|
@ -156,9 +76,9 @@ RUN chmod +x /usr/local/bin/happyDeliver
|
|||
|
||||
# Copy configuration files
|
||||
COPY docker/postfix/ /etc/postfix/
|
||||
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
|
||||
COPY docker/opendkim/ /etc/opendkim/
|
||||
COPY docker/opendmarc/ /etc/opendmarc/
|
||||
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
||||
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
|
||||
COPY docker/supervisor/ /etc/supervisor/
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
|
@ -170,20 +90,11 @@ RUN chmod +x /entrypoint.sh
|
|||
EXPOSE 25 8080
|
||||
|
||||
# Default configuration
|
||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
||||
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
|
||||
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
||||
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
||||
HAPPYDELIVER_DNS_TIMEOUT=5s \
|
||||
HAPPYDELIVER_HTTP_TIMEOUT=10s
|
||||
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
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
|
|
|||
120
README.md
120
README.md
|
|
@ -6,27 +6,25 @@ An open-source email deliverability testing platform that analyzes test emails a
|
|||
|
||||
## 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, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
|
||||
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
|
||||
- **Database Storage**: SQLite or PostgreSQL support
|
||||
- **Configurable**: via environment or config file for all settings
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
### With Docker (Recommended)
|
||||
|
||||
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application.
|
||||
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
|
||||
|
||||
#### What's included in the Docker container:
|
||||
|
||||
- **Postfix MTA**: Receives emails on port 25
|
||||
- **authentication_milter**: Entreprise grade email authentication
|
||||
- **OpenDKIM**: DKIM signature verification
|
||||
- **OpenDMARC**: DMARC policy validation
|
||||
- **SpamAssassin**: Spam scoring and analysis
|
||||
- **rspamd**: Second spam filter for cross-validated scoring
|
||||
- **happyDeliver API**: REST API server on port 8080
|
||||
- **SQLite Database**: Persistent storage for tests and reports
|
||||
|
||||
|
|
@ -38,7 +36,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
|
|||
cd happydeliver
|
||||
|
||||
# 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
|
||||
docker-compose up -d
|
||||
|
|
@ -64,86 +62,12 @@ docker run -d \
|
|||
-p 25:25 \
|
||||
-p 8080:8080 \
|
||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||
--hostname mail.yourdomain.com \
|
||||
-e HOSTNAME=mail.yourdomain.com \
|
||||
-v $(pwd)/data:/var/lib/happydeliver \
|
||||
-v $(pwd)/logs:/var/log/happydeliver \
|
||||
happydeliver:latest
|
||||
```
|
||||
|
||||
#### 3. Configure TLS Certificates (Optional but Recommended)
|
||||
|
||||
To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments.
|
||||
|
||||
##### Using docker-compose
|
||||
|
||||
Add the certificate paths to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt
|
||||
- POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key
|
||||
volumes:
|
||||
- /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro
|
||||
- /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro
|
||||
```
|
||||
|
||||
##### Using docker run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name happydeliver \
|
||||
-p 25:25 \
|
||||
-p 8080:8080 \
|
||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||
-e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \
|
||||
-e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \
|
||||
--hostname mail.yourdomain.com \
|
||||
-v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \
|
||||
-v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \
|
||||
-v $(pwd)/data:/var/lib/happydeliver \
|
||||
-v $(pwd)/logs:/var/log/happydeliver \
|
||||
happydeliver:latest
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The certificate file should contain the full certificate chain (certificate + intermediate CAs)
|
||||
- The private key file must be readable by the postfix user inside the container
|
||||
- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required)
|
||||
- If both environment variables are not set, Postfix will run without TLS support
|
||||
|
||||
#### 4. Configure Network and DNS
|
||||
|
||||
##### Open SMTP Port
|
||||
|
||||
Port 25 (SMTP) must be accessible from the internet to receive test emails:
|
||||
|
||||
```bash
|
||||
# Check if port 25 is listening
|
||||
netstat -ln | grep :25
|
||||
|
||||
# Allow port 25 through firewall (example with ufw)
|
||||
sudo ufw allow 25/tcp
|
||||
|
||||
# For iptables
|
||||
sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT
|
||||
```
|
||||
|
||||
**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support.
|
||||
|
||||
##### Configure DNS Records
|
||||
|
||||
Point your domain to the server's IP address.
|
||||
|
||||
```
|
||||
yourdomain.com. IN A 203.0.113.10
|
||||
yourdomain.com. IN AAAA 2001:db8::10
|
||||
```
|
||||
|
||||
Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly.
|
||||
|
||||
There is no need for an MX record here since the same host will serve both HTTP and SMTP.
|
||||
|
||||
|
||||
### Manual Build
|
||||
|
||||
#### 1. Build
|
||||
|
|
@ -163,7 +87,7 @@ The server will start on `http://localhost:8080` by default.
|
|||
|
||||
#### 3. Integrate with your existing e-mail setup
|
||||
|
||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, 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.
|
||||
|
||||
Choose one of the following way to integrate happyDeliver in your existing setup:
|
||||
|
|
@ -184,9 +108,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi
|
|||
|
||||
```
|
||||
# Transport map - route test emails to happyDeliver LMTP server
|
||||
# Pattern: test-<base32-uuid>@yourdomain.com -> LMTP on localhost:2525
|
||||
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost:2525
|
||||
|
||||
/^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
|
||||
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
|
||||
```
|
||||
|
||||
3. Append the created file to `transport_maps` in your `main.cf`:
|
||||
|
|
@ -220,7 +144,7 @@ Response:
|
|||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
|
||||
"email": "test-550e8400@localhost",
|
||||
"status": "pending",
|
||||
"message": "Send your test email to the address above"
|
||||
}
|
||||
|
|
@ -264,14 +188,22 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
|
|||
|
||||
## Scoring System
|
||||
|
||||
The deliverability score is calculated from A to F based on:
|
||||
The deliverability score is calculated from 0 to 10 based on:
|
||||
|
||||
- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records
|
||||
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
|
||||
- **Blacklist**: RBL/DNSBL checks
|
||||
- **Headers**: Required headers, MIME structure, Domain alignment
|
||||
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
|
||||
- **Content**: HTML quality, links, images, unsubscribe
|
||||
- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
|
||||
- **Spam (2 pts)**: SpamAssassin score
|
||||
- **Blacklist (2 pts)**: RBL/DNSBL checks
|
||||
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
|
||||
- **Headers (1 pt)**: Required headers, MIME structure
|
||||
|
||||
**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor.
|
||||
|
||||
**Ratings:**
|
||||
- 9-10: Excellent
|
||||
- 7-8.9: Good
|
||||
- 5-6.9: Fair
|
||||
- 3-4.9: Poor
|
||||
- 0-2.9: Critical
|
||||
|
||||
## Funding
|
||||
|
||||
|
|
|
|||
1098
api/openapi.yaml
1098
api/openapi.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -29,12 +29,13 @@ import (
|
|||
|
||||
"git.happydns.org/happyDeliver/internal/app"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/version"
|
||||
)
|
||||
|
||||
const version = "0.1.0-dev"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform")
|
||||
fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version)
|
||||
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
|
||||
fmt.Printf("Version: %s\n", version)
|
||||
|
||||
cfg, err := config.ConsolidateConfig()
|
||||
if err != nil {
|
||||
|
|
@ -52,20 +53,8 @@ func main() {
|
|||
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
|
||||
log.Fatalf("Analyzer error: %v", err)
|
||||
}
|
||||
case "backup":
|
||||
if err := app.RunBackup(cfg); err != nil {
|
||||
log.Fatalf("Backup error: %v", err)
|
||||
}
|
||||
case "restore":
|
||||
inputFile := ""
|
||||
if len(flag.Args()) >= 2 {
|
||||
inputFile = flag.Args()[1]
|
||||
}
|
||||
if err := app.RunRestore(cfg, inputFile); err != nil {
|
||||
log.Fatalf("Restore error: %v", err)
|
||||
}
|
||||
case "version":
|
||||
fmt.Println(version.Version)
|
||||
fmt.Println(version)
|
||||
default:
|
||||
fmt.Printf("Unknown command: %s\n", command)
|
||||
printUsage()
|
||||
|
|
@ -75,11 +64,9 @@ func main() {
|
|||
|
||||
func printUsage() {
|
||||
fmt.Println("\nCommand availables:")
|
||||
fmt.Println(" happyDeliver server - Start the API server")
|
||||
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
|
||||
fmt.Println(" happyDeliver backup - Backup database to stdout as JSON")
|
||||
fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin")
|
||||
fmt.Println(" happyDeliver version - Print version information")
|
||||
fmt.Println(" happyDeliver server - Start the API server")
|
||||
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
|
||||
fmt.Println(" happyDeliver version - Print version information")
|
||||
fmt.Println("")
|
||||
flag.Usage()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: happydomain/happydeliver:latest
|
||||
image: happydeliver:latest
|
||||
container_name: happydeliver
|
||||
# Set a hostname
|
||||
hostname: mail.happydeliver.local
|
||||
|
||||
environment:
|
||||
# Set your domain
|
||||
HAPPYDELIVER_DOMAIN: happydeliver.local
|
||||
# Set your domain and hostname
|
||||
DOMAIN: happydeliver.local
|
||||
HOSTNAME: mail.happydeliver.local
|
||||
|
||||
ports:
|
||||
# SMTP port
|
||||
|
|
@ -26,6 +26,13 @@ services:
|
|||
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
data:
|
||||
logs:
|
||||
|
|
|
|||
|
|
@ -109,13 +109,12 @@ Default configuration for the Docker environment:
|
|||
|
||||
The container accepts these environment variables:
|
||||
|
||||
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||
|
||||
Note that the hostname of the container is used to filter the authentication tests results.
|
||||
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
||||
docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
{
|
||||
"logtoerr" : "1",
|
||||
"error_log" : "",
|
||||
"connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock",
|
||||
"umask" : "0007",
|
||||
"runas" : "mail",
|
||||
"rungroup" : "mail",
|
||||
"authserv_id" : "__HOSTNAME__",
|
||||
|
||||
"connect_timeout" : 30,
|
||||
"command_timeout" : 30,
|
||||
"content_timeout" : 300,
|
||||
"dns_timeout" : 10,
|
||||
"dns_retry" : 2,
|
||||
|
||||
"handlers" : {
|
||||
|
||||
"Sanitize" : {
|
||||
"hosts_to_remove" : [
|
||||
"__HOSTNAME__"
|
||||
],
|
||||
"extra_auth_results_types" : [
|
||||
"X-Spam-Status",
|
||||
"X-Spam-Report",
|
||||
"X-Spam-Level",
|
||||
"X-Spam-Checker-Version"
|
||||
]
|
||||
},
|
||||
|
||||
"SPF" : {
|
||||
"hide_none" : 0
|
||||
},
|
||||
|
||||
"DKIM" : {
|
||||
"hide_none" : 0,
|
||||
},
|
||||
|
||||
"XGoogleDKIM" : {
|
||||
"hide_none" : 1,
|
||||
},
|
||||
|
||||
"ARC" : {
|
||||
"hide_none" : 0,
|
||||
},
|
||||
|
||||
"DMARC" : {
|
||||
"hide_none" : 0,
|
||||
"detect_list_id" : "1"
|
||||
},
|
||||
|
||||
"BIMI" : {},
|
||||
|
||||
"PTR" : {},
|
||||
|
||||
"SenderID" : {
|
||||
"hide_none" : 1
|
||||
},
|
||||
|
||||
"IPRev" : {},
|
||||
|
||||
"Auth" : {},
|
||||
|
||||
"AlignedFrom" : {},
|
||||
|
||||
"LocalIP" : {},
|
||||
|
||||
"TrustedIP" : {
|
||||
"trusted_ip_list" : []
|
||||
},
|
||||
|
||||
"!AddID" : {},
|
||||
|
||||
"ReturnOK" : {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
; This is YOU. DMARC reports include information about the reports. Enter it here.
|
||||
[organization]
|
||||
domain = example.com
|
||||
org_name = My Company Limited
|
||||
email = admin@example.com
|
||||
extra_contact_info = http://example.com
|
||||
|
||||
; aggregate DMARC reports need to be stored somewhere. Any database
|
||||
; with a DBI module (MySQL, SQLite, DBD, etc.) should work.
|
||||
; SQLite and MySQL are tested.
|
||||
; Default is sqlite.
|
||||
[report_store]
|
||||
backend = SQL
|
||||
;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite
|
||||
dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306
|
||||
user = authmilterusername
|
||||
pass = authmiltpassword
|
||||
|
||||
; backend can be perl or libopendmarc
|
||||
[dmarc]
|
||||
backend = perl
|
||||
|
||||
[dns]
|
||||
timeout = 5
|
||||
public_suffix_list = share/public_suffix_list
|
||||
|
||||
[smtp]
|
||||
; hostname is the external FQDN of this MTA
|
||||
hostname = mx1.example.com
|
||||
cc = dmarc.copy@example.com
|
||||
|
||||
; list IP addresses to whitelist (bypass DMARC reject/quarantine)
|
||||
; see sample whitelist in share/dmarc_whitelist
|
||||
whitelist = /path/to/etc/dmarc_whitelist
|
||||
|
||||
; By default, we attempt to email directly to the report recipient.
|
||||
; Set these to relay via a SMTP smart host.
|
||||
smarthost = mx2.example.com
|
||||
smartuser = dmarccopyusername
|
||||
smartpass = dmarccopypassword
|
||||
|
||||
[imap]
|
||||
server = mail.example.com
|
||||
user =
|
||||
pass =
|
||||
; the imap folder where new dmarc messages will be found
|
||||
folder = dmarc
|
||||
; the folders to store processed reports (a=aggregate, f=forensic)
|
||||
f_done = dmarc.forensic
|
||||
a_done = dmarc.aggregate
|
||||
|
||||
[http]
|
||||
port = 8080
|
||||
|
||||
[https]
|
||||
port = 8443
|
||||
ssl_crt =
|
||||
ssl_key =
|
||||
|
|
@ -4,42 +4,34 @@ set -e
|
|||
echo "Starting happyDeliver container..."
|
||||
|
||||
# Get environment variables with defaults
|
||||
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
|
||||
HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
|
||||
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
|
||||
|
||||
echo "Hostname: $HOSTNAME"
|
||||
echo "Domain: $HAPPYDELIVER_DOMAIN"
|
||||
|
||||
# Create socket directories
|
||||
mkdir -p /var/spool/postfix/authentication_milter
|
||||
chown mail:mail /var/spool/postfix/authentication_milter
|
||||
chmod 750 /var/spool/postfix/authentication_milter
|
||||
# Create runtime directories
|
||||
mkdir -p /var/run/opendkim /var/run/opendmarc
|
||||
chown opendkim:postfix /var/run/opendkim
|
||||
chown opendmarc:postfix /var/run/opendmarc
|
||||
|
||||
mkdir -p /var/spool/postfix/rspamd
|
||||
chown rspamd:mail /var/spool/postfix/rspamd
|
||||
chmod 750 /var/spool/postfix/rspamd
|
||||
# Create socket directories
|
||||
mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
|
||||
chown opendkim:postfix /var/spool/postfix/opendkim
|
||||
chown opendmarc:postfix /var/spool/postfix/opendmarc
|
||||
chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
|
||||
|
||||
# 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
|
||||
chown happydeliver:happydeliver /var/log/happydeliver
|
||||
chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter
|
||||
|
||||
# Replace placeholders in Postfix configuration
|
||||
echo "Configuring Postfix..."
|
||||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
|
||||
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
|
||||
|
||||
# Add certificates to postfix
|
||||
[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && {
|
||||
cat <<EOF >> /etc/postfix/main.cf
|
||||
smtpd_tls_cert_file = ${POSTFIX_CERT_FILE}
|
||||
smtpd_tls_key_file = ${POSTFIX_KEY_FILE}
|
||||
smtpd_tls_security_level = may
|
||||
EOF
|
||||
}
|
||||
|
||||
# Replace placeholders in configurations
|
||||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
|
||||
# Replace placeholders in OpenDMARC configuration
|
||||
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf
|
||||
|
||||
# Initialize Postfix aliases
|
||||
if [ -f /etc/postfix/aliases ]; then
|
||||
|
|
|
|||
39
docker/opendkim/opendkim.conf
Normal file
39
docker/opendkim/opendkim.conf
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# OpenDKIM configuration for happyDeliver
|
||||
# Verifies DKIM signatures on incoming emails
|
||||
|
||||
# Log to syslog
|
||||
Syslog yes
|
||||
SyslogSuccess yes
|
||||
LogWhy yes
|
||||
|
||||
# Run as this user and group
|
||||
UserID opendkim:mail
|
||||
|
||||
UMask 002
|
||||
|
||||
# Socket for Postfix communication
|
||||
Socket unix:/var/spool/postfix/opendkim/opendkim.sock
|
||||
|
||||
# Process ID file
|
||||
PidFile /var/run/opendkim/opendkim.pid
|
||||
|
||||
# Operating mode - verify only (not signing)
|
||||
Mode v
|
||||
|
||||
# Canonicalization methods
|
||||
Canonicalization relaxed/simple
|
||||
|
||||
# DNS timeout
|
||||
DNSTimeout 5
|
||||
|
||||
# Add header for verification results
|
||||
AlwaysAddARHeader yes
|
||||
|
||||
# Accept unsigned mail
|
||||
On-NoSignature accept
|
||||
|
||||
# Always add Authentication-Results header
|
||||
AlwaysAddARHeader yes
|
||||
|
||||
# Maximum verification attempts
|
||||
MaximumSignaturesToVerify 3
|
||||
41
docker/opendmarc/opendmarc.conf
Normal file
41
docker/opendmarc/opendmarc.conf
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# OpenDMARC configuration for happyDeliver
|
||||
# Verifies DMARC policies on incoming emails
|
||||
|
||||
# Socket for Postfix communication
|
||||
Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock
|
||||
|
||||
# Process ID file
|
||||
PidFile /var/run/opendmarc/opendmarc.pid
|
||||
|
||||
# Run as this user and group
|
||||
UserID opendmarc:mail
|
||||
|
||||
UMask 002
|
||||
|
||||
# Syslog configuration
|
||||
Syslog true
|
||||
SyslogFacility mail
|
||||
|
||||
# Ignore authentication results from other hosts
|
||||
IgnoreAuthenticatedClients true
|
||||
|
||||
# Accept mail even if DMARC fails (we're analyzing, not filtering)
|
||||
RejectFailures false
|
||||
|
||||
# Trust Authentication-Results headers from localhost only
|
||||
TrustedAuthservIDs __HOSTNAME__
|
||||
|
||||
# Add DMARC results to Authentication-Results header
|
||||
#AddAuthenticationResults true
|
||||
|
||||
# DNS timeout
|
||||
DNSTimeout 5
|
||||
|
||||
# History file (for reporting)
|
||||
# HistoryFile /var/spool/opendmarc/opendmarc.dat
|
||||
|
||||
# Ignore hosts file
|
||||
# IgnoreHosts /etc/opendmarc/ignore.hosts
|
||||
|
||||
# Public suffix list
|
||||
# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat
|
||||
|
|
@ -10,7 +10,7 @@ inet_interfaces = all
|
|||
inet_protocols = ipv4
|
||||
|
||||
# Recipient settings
|
||||
mydestination = localhost.$mydomain, localhost
|
||||
mydestination = $myhostname, localhost.$mydomain, localhost
|
||||
mynetworks = 127.0.0.0/8 [::1]/128
|
||||
|
||||
# Relay settings - accept mail for our test domain
|
||||
|
|
@ -28,13 +28,14 @@ transport_maps = pcre:/etc/postfix/transport_maps
|
|||
# OpenDKIM for DKIM verification
|
||||
milter_default_action = accept
|
||||
milter_protocol = 6
|
||||
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
|
||||
smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock
|
||||
non_smtpd_milters = $smtpd_milters
|
||||
|
||||
# SPF policy checking
|
||||
smtpd_recipient_restrictions =
|
||||
permit_mynetworks,
|
||||
reject_unauth_destination
|
||||
reject_unauth_destination,
|
||||
check_policy_service unix:private/policy-spf
|
||||
|
||||
# Logging
|
||||
debug_peer_level = 2
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
# SMTP service
|
||||
smtp inet n - n - - smtpd
|
||||
-o content_filter=spamassassin
|
||||
|
||||
# Pickup service
|
||||
pickup unix n - n 60 1 pickup
|
||||
|
|
@ -73,6 +74,10 @@ scache unix - - n - 1 scache
|
|||
maildrop unix - n n - - pipe
|
||||
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
|
||||
|
||||
# SPF policy service
|
||||
policy-spf unix - n n - 0 spawn
|
||||
user=nobody argv=/usr/bin/postfix-policyd-spf-perl
|
||||
|
||||
# SpamAssassin content filter
|
||||
spamassassin unix - n n - - pipe
|
||||
user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Transport map - route test emails to happyDeliver LMTP server
|
||||
# Pattern: test-<base32-uuid>@domain.com -> LMTP on localhost:2525
|
||||
# Pattern: test-<uuid>@domain.com -> LMTP on localhost:2525
|
||||
|
||||
/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525
|
||||
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525
|
||||
|
|
|
|||
|
|
@ -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
|
||||
user_scores_dsn_timeout 3
|
||||
user_scores_sql_override 0
|
||||
|
||||
# Disable Validity network rules
|
||||
dns_query_restriction deny sa-trusted.bondedsender.org
|
||||
dns_query_restriction deny sa-accredit.habeas.com
|
||||
dns_query_restriction deny bl.score.senderscore.com
|
||||
score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_CERTIFIED 0
|
||||
score RCVD_IN_VALIDITY_RPBL 0
|
||||
score RCVD_IN_VALIDITY_SAFE 0
|
||||
|
|
@ -22,26 +22,27 @@ autostart=true
|
|||
autorestart=true
|
||||
priority=9
|
||||
|
||||
# Authentication Milter service
|
||||
[program:authentication_milter]
|
||||
command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid
|
||||
# OpenDKIM service
|
||||
[program:opendkim]
|
||||
command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_logfile=/var/log/happydeliver/authentication_milter.log
|
||||
stderr_logfile=/var/log/happydeliver/authentication_milter.log
|
||||
user=mail
|
||||
stdout_logfile=/var/log/happydeliver/opendkim.log
|
||||
stderr_logfile=/var/log/happydeliver/opendkim_error.log
|
||||
user=opendkim
|
||||
group=mail
|
||||
|
||||
# rspamd spam filter
|
||||
[program:rspamd]
|
||||
command=/usr/bin/rspamd -f -u rspamd -g mail
|
||||
# OpenDMARC service
|
||||
[program:opendmarc]
|
||||
command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=11
|
||||
stdout_logfile=/var/log/happydeliver/rspamd.log
|
||||
stderr_logfile=/var/log/happydeliver/rspamd_error.log
|
||||
user=root
|
||||
stdout_logfile=/var/log/happydeliver/opendmarc.log
|
||||
stderr_logfile=/var/log/happydeliver/opendmarc_error.log
|
||||
user=opendmarc
|
||||
group=mail
|
||||
|
||||
# SpamAssassin daemon
|
||||
[program:spamd]
|
||||
|
|
@ -53,18 +54,6 @@ stdout_logfile=/var/log/happydeliver/spamd.log
|
|||
stderr_logfile=/var/log/happydeliver/spamd_error.log
|
||||
user=root
|
||||
|
||||
# SpamAssassin milter
|
||||
[program:spamass_milter]
|
||||
command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=7
|
||||
stdout_logfile=/var/log/happydeliver/spamass-milter.log
|
||||
stderr_logfile=/var/log/happydeliver/spamass-milter_error.log
|
||||
user=mail
|
||||
group=mail
|
||||
umask=007
|
||||
|
||||
# Postfix service
|
||||
[program:postfix]
|
||||
command=/usr/sbin/postfix start-fg
|
||||
|
|
|
|||
60
go.mod
60
go.mod
|
|
@ -3,40 +3,36 @@ module git.happydns.org/happyDeliver
|
|||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/getkin/kin-openapi v0.132.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/net v0.46.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // 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/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
|
@ -44,35 +40,33 @@ require (
|
|||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.1 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
130
go.sum
130
go.sum
|
|
@ -1,21 +1,11 @@
|
|||
github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
|
|
@ -24,8 +14,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
|
|
@ -36,35 +24,33 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
|
|||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
|
|
@ -90,8 +76,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
|
|
@ -114,12 +100,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -130,8 +116,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
|
|||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
|
||||
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
||||
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
|
|
@ -156,12 +142,10 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
|
|||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
|
||||
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
|
|
@ -174,38 +158,34 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
|
@ -213,13 +193,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
|||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -235,21 +215,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -262,8 +242,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
|
@ -285,5 +265,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
|||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
|
|
|||
|
|
@ -32,32 +32,20 @@ import (
|
|||
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
"git.happydns.org/happyDeliver/internal/version"
|
||||
)
|
||||
|
||||
// EmailAnalyzer defines the interface for email analysis
|
||||
// This interface breaks the circular dependency with pkg/analyzer
|
||||
type EmailAnalyzer interface {
|
||||
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
||||
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
||||
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||
}
|
||||
|
||||
// APIHandler implements the ServerInterface for handling API requests
|
||||
type APIHandler struct {
|
||||
storage storage.Storage
|
||||
config *config.Config
|
||||
analyzer EmailAnalyzer
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// NewAPIHandler creates a new API handler
|
||||
func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler {
|
||||
func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler {
|
||||
return &APIHandler{
|
||||
storage: store,
|
||||
config: cfg,
|
||||
analyzer: analyzer,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
|
@ -68,41 +56,27 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
|
|||
// Generate a unique test ID (no database record created)
|
||||
testID := uuid.New()
|
||||
|
||||
// Convert UUID to base32 string for the API response
|
||||
base32ID := utils.UUIDToBase32(testID)
|
||||
|
||||
// Generate test email address using Base32-encoded UUID
|
||||
// Generate test email address
|
||||
email := fmt.Sprintf("%s%s@%s",
|
||||
h.config.Email.TestAddressPrefix,
|
||||
base32ID,
|
||||
testID.String(),
|
||||
h.config.Email.Domain,
|
||||
)
|
||||
|
||||
// Return response
|
||||
c.JSON(http.StatusCreated, TestResponse{
|
||||
Id: base32ID,
|
||||
Id: testID,
|
||||
Email: openapi_types.Email(email),
|
||||
Status: TestResponseStatusPending,
|
||||
Message: stringPtr("Send your test email to the given address"),
|
||||
Message: stringPtr("Send your test email to the address above"),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTest retrieves test metadata
|
||||
// (GET /test/{id})
|
||||
func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||
// Convert base32 ID to UUID
|
||||
testUUID, err := utils.Base32ToUUID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_id",
|
||||
Message: "Invalid test ID format",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
|
||||
// Check if a report exists for this test ID
|
||||
reportExists, err := h.storage.ReportExists(testUUID)
|
||||
reportExists, err := h.storage.ReportExists(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
|
|
@ -120,35 +94,29 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
|||
apiStatus = TestStatusPending
|
||||
}
|
||||
|
||||
// Generate test email address using Base32-encoded UUID
|
||||
// Generate test email address
|
||||
email := fmt.Sprintf("%s%s@%s",
|
||||
h.config.Email.TestAddressPrefix,
|
||||
id,
|
||||
id.String(),
|
||||
h.config.Email.Domain,
|
||||
)
|
||||
|
||||
// Return current time for CreatedAt/UpdatedAt since we don't track tests anymore
|
||||
now := time.Now()
|
||||
|
||||
c.JSON(http.StatusOK, Test{
|
||||
Id: id,
|
||||
Email: openapi_types.Email(email),
|
||||
Status: apiStatus,
|
||||
Id: id,
|
||||
Email: openapi_types.Email(email),
|
||||
Status: apiStatus,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: &now,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReport retrieves the detailed analysis report
|
||||
// (GET /report/{id})
|
||||
func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
||||
// Convert base32 ID to UUID
|
||||
testUUID, err := utils.Base32ToUUID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_id",
|
||||
Message: "Invalid test ID format",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reportJSON, _, err := h.storage.GetReport(testUUID)
|
||||
func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
|
||||
reportJSON, _, err := h.storage.GetReport(id)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, Error{
|
||||
|
|
@ -171,19 +139,8 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
|||
|
||||
// GetRawEmail retrieves the raw annotated email
|
||||
// (GET /report/{id}/raw)
|
||||
func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
||||
// Convert base32 ID to UUID
|
||||
testUUID, err := utils.Base32ToUUID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_id",
|
||||
Message: "Invalid test ID format",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, rawEmail, err := h.storage.GetReport(testUUID)
|
||||
func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
|
||||
_, rawEmail, err := h.storage.GetReport(id)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, Error{
|
||||
|
|
@ -203,63 +160,6 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
|||
c.Data(http.StatusOK, "text/plain", rawEmail)
|
||||
}
|
||||
|
||||
// ReanalyzeReport re-analyzes an existing email and regenerates the report
|
||||
// (POST /report/{id}/reanalyze)
|
||||
func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||
// Convert base32 ID to UUID
|
||||
testUUID, err := utils.Base32ToUUID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_id",
|
||||
Message: "Invalid test ID format",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the existing report (mainly to get the raw email)
|
||||
_, rawEmail, err := h.storage.GetReport(testUUID)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, Error{
|
||||
Error: "not_found",
|
||||
Message: "Email not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
Message: "Failed to retrieve email",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-analyze the email using the current analyzer
|
||||
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "analysis_error",
|
||||
Message: "Failed to re-analyze email",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the report in storage
|
||||
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
Message: "Failed to update report",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the updated report JSON directly
|
||||
c.Data(http.StatusOK, "application/json", reportJSON)
|
||||
}
|
||||
|
||||
// GetStatus retrieves service health status
|
||||
// (GET /status)
|
||||
func (h *APIHandler) GetStatus(c *gin.Context) {
|
||||
|
|
@ -281,7 +181,7 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
|
|||
mtaStatus := StatusComponentsMtaUp
|
||||
c.JSON(http.StatusOK, Status{
|
||||
Status: overallStatus,
|
||||
Version: version.Version,
|
||||
Version: "0.1.0-dev",
|
||||
Components: &struct {
|
||||
Database *StatusComponentsDatabase `json:"database,omitempty"`
|
||||
Mta *StatusComponentsMta `json:"mta,omitempty"`
|
||||
|
|
@ -292,92 +192,3 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
|
|||
Uptime: &uptime,
|
||||
})
|
||||
}
|
||||
|
||||
// TestDomain performs synchronous domain analysis
|
||||
// (POST /domain)
|
||||
func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||
var request DomainTestRequest
|
||||
|
||||
// Bind and validate request
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_request",
|
||||
Message: "Invalid request body",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Perform domain analysis
|
||||
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
|
||||
|
||||
// Convert grade string to DomainTestResponseGrade enum
|
||||
var responseGrade DomainTestResponseGrade
|
||||
switch grade {
|
||||
case "A+":
|
||||
responseGrade = DomainTestResponseGradeA
|
||||
case "A":
|
||||
responseGrade = DomainTestResponseGradeA1
|
||||
case "B":
|
||||
responseGrade = DomainTestResponseGradeB
|
||||
case "C":
|
||||
responseGrade = DomainTestResponseGradeC
|
||||
case "D":
|
||||
responseGrade = DomainTestResponseGradeD
|
||||
case "E":
|
||||
responseGrade = DomainTestResponseGradeE
|
||||
case "F":
|
||||
responseGrade = DomainTestResponseGradeF
|
||||
default:
|
||||
responseGrade = DomainTestResponseGradeF
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := DomainTestResponse{
|
||||
Domain: request.Domain,
|
||||
Score: score,
|
||||
Grade: responseGrade,
|
||||
DnsResults: *dnsResults,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// CheckBlacklist checks an IP address against DNS blacklists
|
||||
// (POST /blacklist)
|
||||
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||
var request BlacklistCheckRequest
|
||||
|
||||
// Bind and validate request
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_request",
|
||||
Message: "Invalid request body",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Perform blacklist check using analyzer
|
||||
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_ip",
|
||||
Message: "Invalid IP address",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := BlacklistCheckResponse{
|
||||
Ip: request.Ip,
|
||||
Blacklists: checks,
|
||||
Whitelists: &whitelists,
|
||||
ListedCount: listedCount,
|
||||
Score: score,
|
||||
Grade: BlacklistCheckResponseGrade(grade),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/pkg/analyzer"
|
||||
)
|
||||
|
|
@ -86,549 +87,57 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
|
|||
|
||||
// outputHumanReadable outputs a human-readable summary
|
||||
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
|
||||
report := result.Report
|
||||
|
||||
// Header with overall score
|
||||
// Header
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
|
||||
fmt.Fprintln(writer, strings.Repeat("=", 70))
|
||||
fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade)
|
||||
fmt.Fprintf(writer, "Test ID: %s\n", report.TestId)
|
||||
fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
||||
|
||||
// Score Summary
|
||||
if report.Summary != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "SCORE BREAKDOWN")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
// Score summary
|
||||
summary := emailAnalyzer.GetScoreSummaryText(result)
|
||||
fmt.Fprintln(writer, summary)
|
||||
|
||||
summary := report.Summary
|
||||
fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n",
|
||||
summary.DnsScore, summary.DnsGrade)
|
||||
fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n",
|
||||
summary.AuthenticationScore, summary.AuthenticationGrade)
|
||||
fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n",
|
||||
summary.BlacklistScore, summary.BlacklistGrade)
|
||||
fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n",
|
||||
summary.HeaderScore, summary.HeaderGrade)
|
||||
fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n",
|
||||
summary.SpamScore, summary.SpamGrade)
|
||||
fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n",
|
||||
summary.ContentScore, summary.ContentGrade)
|
||||
// Detailed checks
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
// Group checks by category
|
||||
categories := make(map[api.CheckCategory][]api.Check)
|
||||
for _, check := range result.Report.Checks {
|
||||
categories[check.Category] = append(categories[check.Category], check)
|
||||
}
|
||||
|
||||
// DNS Results
|
||||
if report.DnsResults != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "DNS CONFIGURATION")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
// Print checks by category
|
||||
categoryOrder := []api.CheckCategory{
|
||||
api.Authentication,
|
||||
api.Dns,
|
||||
api.Blacklist,
|
||||
api.Content,
|
||||
api.Headers,
|
||||
}
|
||||
|
||||
dns := report.DnsResults
|
||||
fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain)
|
||||
if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain {
|
||||
fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain)
|
||||
for _, category := range categoryOrder {
|
||||
checks, ok := categories[category]
|
||||
if !ok || len(checks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// MX Records
|
||||
if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n MX Records (From Domain):")
|
||||
for _, mx := range *dns.FromMxRecords {
|
||||
status := "✓"
|
||||
if !mx.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host)
|
||||
if mx.Error != nil {
|
||||
fmt.Fprintf(writer, " - ERROR: %s", *mx.Error)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintf(writer, "\n%s:\n", category)
|
||||
for _, check := range checks {
|
||||
statusSymbol := "✓"
|
||||
if check.Status == api.CheckStatusFail {
|
||||
statusSymbol = "✗"
|
||||
} else if check.Status == api.CheckStatusWarn {
|
||||
statusSymbol = "⚠"
|
||||
}
|
||||
}
|
||||
|
||||
// SPF Records
|
||||
if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n SPF Records:")
|
||||
for _, spf := range *dns.SpfRecords {
|
||||
status := "✓"
|
||||
if !spf.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s ", status)
|
||||
if spf.Domain != nil {
|
||||
fmt.Fprintf(writer, "Domain: %s", *spf.Domain)
|
||||
}
|
||||
if spf.AllQualifier != nil {
|
||||
fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if spf.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *spf.Record)
|
||||
}
|
||||
if spf.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM Records
|
||||
if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n DKIM Records:")
|
||||
for _, dkim := range *dns.DkimRecords {
|
||||
status := "✓"
|
||||
if !dkim.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain)
|
||||
if dkim.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dkim.Record)
|
||||
}
|
||||
if dkim.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC Record
|
||||
if dns.DmarcRecord != nil {
|
||||
fmt.Fprintln(writer, "\n DMARC Record:")
|
||||
status := "✓"
|
||||
if !dns.DmarcRecord.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid)
|
||||
if dns.DmarcRecord.Policy != nil {
|
||||
fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy)
|
||||
}
|
||||
if dns.DmarcRecord.SubdomainPolicy != nil {
|
||||
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if dns.DmarcRecord.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
||||
}
|
||||
if dns.DmarcRecord.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// BIMI Record
|
||||
if dns.BimiRecord != nil {
|
||||
fmt.Fprintln(writer, "\n BIMI Record:")
|
||||
status := "✓"
|
||||
if !dns.BimiRecord.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n",
|
||||
status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain)
|
||||
if dns.BimiRecord.LogoUrl != nil {
|
||||
fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl)
|
||||
}
|
||||
if dns.BimiRecord.VmcUrl != nil {
|
||||
fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl)
|
||||
}
|
||||
if dns.BimiRecord.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record)
|
||||
}
|
||||
if dns.BimiRecord.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// PTR Records
|
||||
if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:")
|
||||
for _, ptr := range *dns.PtrRecords {
|
||||
fmt.Fprintf(writer, " %s\n", ptr)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS Errors
|
||||
if dns.Errors != nil && len(*dns.Errors) > 0 {
|
||||
fmt.Fprintln(writer, "\n DNS Errors:")
|
||||
for _, err := range *dns.Errors {
|
||||
fmt.Fprintf(writer, " ! %s\n", err)
|
||||
fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
|
||||
if check.Advice != nil && *check.Advice != "" {
|
||||
fmt.Fprintf(writer, " → %s\n", *check.Advice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication Results
|
||||
if report.Authentication != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "AUTHENTICATION RESULTS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
auth := report.Authentication
|
||||
|
||||
// SPF
|
||||
if auth.Spf != nil {
|
||||
fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result)))
|
||||
if auth.Spf.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain)
|
||||
}
|
||||
if auth.Spf.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// DKIM
|
||||
if auth.Dkim != nil && len(*auth.Dkim) > 0 {
|
||||
fmt.Fprintln(writer, "\n DKIM:")
|
||||
for i, dkim := range *auth.Dkim {
|
||||
fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result)))
|
||||
if dkim.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s", *dkim.Domain)
|
||||
if dkim.Selector != nil {
|
||||
fmt.Fprintf(writer, ", selector: %s", *dkim.Selector)
|
||||
}
|
||||
fmt.Fprintf(writer, ")")
|
||||
}
|
||||
if dkim.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *dkim.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC
|
||||
if auth.Dmarc != nil {
|
||||
fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result)))
|
||||
if auth.Dmarc.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain)
|
||||
}
|
||||
if auth.Dmarc.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// ARC
|
||||
if auth.Arc != nil {
|
||||
fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result)))
|
||||
if auth.Arc.ChainLength != nil {
|
||||
fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength)
|
||||
}
|
||||
if auth.Arc.ChainValid != nil {
|
||||
fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid)
|
||||
}
|
||||
if auth.Arc.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// BIMI
|
||||
if auth.Bimi != nil {
|
||||
fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result)))
|
||||
if auth.Bimi.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain)
|
||||
}
|
||||
if auth.Bimi.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// IP Reverse
|
||||
if auth.Iprev != nil {
|
||||
fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result)))
|
||||
if auth.Iprev.Ip != nil {
|
||||
fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip)
|
||||
if auth.Iprev.Hostname != nil {
|
||||
fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname)
|
||||
}
|
||||
fmt.Fprintf(writer, ")")
|
||||
}
|
||||
if auth.Iprev.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// Blacklist Results
|
||||
if report.Blacklists != nil && len(*report.Blacklists) > 0 {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "BLACKLIST CHECKS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
totalChecks := 0
|
||||
totalListed := 0
|
||||
for ip, checks := range *report.Blacklists {
|
||||
totalChecks += len(checks)
|
||||
fmt.Fprintf(writer, "\n IP Address: %s\n", ip)
|
||||
for _, check := range checks {
|
||||
status := "✓"
|
||||
if check.Listed {
|
||||
status = "✗"
|
||||
totalListed++
|
||||
}
|
||||
fmt.Fprintf(writer, " %s %s", status, check.Rbl)
|
||||
if check.Listed {
|
||||
fmt.Fprintf(writer, " - LISTED")
|
||||
if check.Response != nil {
|
||||
fmt.Fprintf(writer, " (%s)", *check.Response)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(writer, " - OK")
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if check.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *check.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks)
|
||||
}
|
||||
|
||||
// Header Analysis
|
||||
if report.HeaderAnalysis != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "HEADER ANALYSIS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
header := report.HeaderAnalysis
|
||||
|
||||
// Domain Alignment
|
||||
if header.DomainAlignment != nil {
|
||||
fmt.Fprintln(writer, "\n Domain Alignment:")
|
||||
align := header.DomainAlignment
|
||||
if align.FromDomain != nil {
|
||||
fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain)
|
||||
if align.FromOrgDomain != nil {
|
||||
fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
if align.ReturnPathDomain != nil {
|
||||
fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain)
|
||||
if align.ReturnPathOrgDomain != nil {
|
||||
fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
if align.Aligned != nil {
|
||||
fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned)
|
||||
}
|
||||
if align.RelaxedAligned != nil {
|
||||
fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned)
|
||||
}
|
||||
if align.Issues != nil && len(*align.Issues) > 0 {
|
||||
fmt.Fprintln(writer, " Issues:")
|
||||
for _, issue := range *align.Issues {
|
||||
fmt.Fprintf(writer, " - %s\n", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required/Important Headers
|
||||
if header.Headers != nil {
|
||||
fmt.Fprintln(writer, "\n Standard Headers:")
|
||||
importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"}
|
||||
for _, hdrName := range importantHeaders {
|
||||
if hdr, ok := (*header.Headers)[hdrName]; ok {
|
||||
status := "✗"
|
||||
if hdr.Present {
|
||||
status = "✓"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName))
|
||||
if hdr.Present {
|
||||
if hdr.Valid != nil && !*hdr.Valid {
|
||||
fmt.Fprintf(writer, "INVALID")
|
||||
} else {
|
||||
fmt.Fprintf(writer, "OK")
|
||||
}
|
||||
if hdr.Importance != nil {
|
||||
fmt.Fprintf(writer, " [%s]", *hdr.Importance)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(writer, "MISSING")
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if hdr.Issues != nil && len(*hdr.Issues) > 0 {
|
||||
for _, issue := range *hdr.Issues {
|
||||
fmt.Fprintf(writer, " - %s\n", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header Issues
|
||||
if header.Issues != nil && len(*header.Issues) > 0 {
|
||||
fmt.Fprintln(writer, "\n Header Issues:")
|
||||
for _, issue := range *header.Issues {
|
||||
fmt.Fprintf(writer, " [%s] %s: %s\n",
|
||||
strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message)
|
||||
if issue.Advice != nil {
|
||||
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Received Chain
|
||||
if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 {
|
||||
fmt.Fprintln(writer, "\n Email Path (Received Chain):")
|
||||
for i, hop := range *header.ReceivedChain {
|
||||
fmt.Fprintf(writer, " [%d] ", i+1)
|
||||
if hop.From != nil {
|
||||
fmt.Fprintf(writer, "%s", *hop.From)
|
||||
if hop.Ip != nil {
|
||||
fmt.Fprintf(writer, " (%s)", *hop.Ip)
|
||||
}
|
||||
}
|
||||
if hop.By != nil {
|
||||
fmt.Fprintf(writer, " -> %s", *hop.By)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if hop.Timestamp != nil {
|
||||
fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SpamAssassin Results
|
||||
if report.Spamassassin != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
sa := report.Spamassassin
|
||||
fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore)
|
||||
if sa.IsSpam {
|
||||
fmt.Fprintf(writer, " (SPAM)")
|
||||
} else {
|
||||
fmt.Fprintf(writer, " (HAM)")
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
|
||||
if sa.Version != nil {
|
||||
fmt.Fprintf(writer, " Version: %s\n", *sa.Version)
|
||||
}
|
||||
|
||||
if len(sa.TestDetails) > 0 {
|
||||
fmt.Fprintln(writer, "\n Triggered Tests:")
|
||||
for _, test := range sa.TestDetails {
|
||||
scoreStr := "+"
|
||||
if test.Score < 0 {
|
||||
scoreStr = ""
|
||||
}
|
||||
fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name)
|
||||
if test.Description != nil {
|
||||
fmt.Fprintf(writer, "\n %s", *test.Description)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content Analysis
|
||||
if report.ContentAnalysis != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "CONTENT ANALYSIS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
content := report.ContentAnalysis
|
||||
|
||||
// Basic content info
|
||||
fmt.Fprintln(writer, "\n Content Structure:")
|
||||
if content.HasPlaintext != nil {
|
||||
fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext)
|
||||
}
|
||||
if content.HasHtml != nil {
|
||||
fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml)
|
||||
}
|
||||
if content.TextToImageRatio != nil {
|
||||
fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio)
|
||||
}
|
||||
|
||||
// Unsubscribe
|
||||
if content.HasUnsubscribeLink != nil {
|
||||
fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink)
|
||||
if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 {
|
||||
fmt.Fprintf(writer, " Unsubscribe Methods: ")
|
||||
for i, method := range *content.UnsubscribeMethods {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(writer, ", ")
|
||||
}
|
||||
fmt.Fprintf(writer, "%s", method)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// Links
|
||||
if content.Links != nil && len(*content.Links) > 0 {
|
||||
fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links))
|
||||
for _, link := range *content.Links {
|
||||
status := ""
|
||||
switch link.Status {
|
||||
case "valid":
|
||||
status = "✓"
|
||||
case "broken":
|
||||
status = "✗"
|
||||
case "suspicious":
|
||||
status = "⚠"
|
||||
case "redirected":
|
||||
status = "→"
|
||||
case "timeout":
|
||||
status = "⏱"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url)
|
||||
if link.HttpCode != nil {
|
||||
fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if link.RedirectChain != nil && len(*link.RedirectChain) > 0 {
|
||||
fmt.Fprintln(writer, " Redirect chain:")
|
||||
for _, url := range *link.RedirectChain {
|
||||
fmt.Fprintf(writer, " -> %s\n", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
if content.Images != nil && len(*content.Images) > 0 {
|
||||
fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images))
|
||||
missingAlt := 0
|
||||
trackingPixels := 0
|
||||
for _, img := range *content.Images {
|
||||
if !img.HasAlt {
|
||||
missingAlt++
|
||||
}
|
||||
if img.IsTrackingPixel != nil && *img.IsTrackingPixel {
|
||||
trackingPixels++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(writer, " Images with ALT text: %d/%d\n",
|
||||
len(*content.Images)-missingAlt, len(*content.Images))
|
||||
if trackingPixels > 0 {
|
||||
fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels)
|
||||
}
|
||||
}
|
||||
|
||||
// HTML Issues
|
||||
if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 {
|
||||
fmt.Fprintln(writer, "\n Content Issues:")
|
||||
for _, issue := range *content.HtmlIssues {
|
||||
fmt.Fprintf(writer, " [%s] %s: %s\n",
|
||||
strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message)
|
||||
if issue.Location != nil {
|
||||
fmt.Fprintf(writer, " Location: %s\n", *issue.Location)
|
||||
}
|
||||
if issue.Advice != nil {
|
||||
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||
fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n")
|
||||
fmt.Fprintln(writer, strings.Repeat("=", 70))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
)
|
||||
|
||||
// BackupData represents the structure of a backup file
|
||||
type BackupData struct {
|
||||
Version string `json:"version"`
|
||||
Reports []storage.Report `json:"reports"`
|
||||
}
|
||||
|
||||
// RunBackup exports the database to stdout as JSON
|
||||
func RunBackup(cfg *config.Config) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
|
||||
|
||||
// Get all reports from the database
|
||||
reports, err := storage.GetAllReports(store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve reports: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports))
|
||||
|
||||
// Create backup data structure
|
||||
backup := BackupData{
|
||||
Version: "1.0",
|
||||
Reports: reports,
|
||||
}
|
||||
|
||||
// Encode to JSON and write to stdout
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(backup); err != nil {
|
||||
return fmt.Errorf("failed to encode backup data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunRestore imports the database from a JSON file or stdin
|
||||
func RunRestore(cfg *config.Config, inputPath string) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine input source
|
||||
var reader io.Reader
|
||||
if inputPath == "" || inputPath == "-" {
|
||||
fmt.Fprintln(os.Stderr, "Reading backup from stdin...")
|
||||
reader = os.Stdin
|
||||
} else {
|
||||
inFile, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open backup file: %w", err)
|
||||
}
|
||||
defer inFile.Close()
|
||||
fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath)
|
||||
reader = inFile
|
||||
}
|
||||
|
||||
// Decode JSON
|
||||
var backup BackupData
|
||||
decoder := json.NewDecoder(reader)
|
||||
if err := decoder.Decode(&backup); err != nil {
|
||||
if err == io.EOF {
|
||||
return fmt.Errorf("backup file is empty or corrupted")
|
||||
}
|
||||
return fmt.Errorf("failed to decode backup data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version)
|
||||
fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports))
|
||||
|
||||
// Initialize storage
|
||||
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
|
||||
|
||||
// Restore reports
|
||||
restored, skipped, failed := 0, 0, 0
|
||||
for _, report := range backup.Reports {
|
||||
// Check if report already exists
|
||||
exists, err := store.ReportExists(report.TestID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if exists {
|
||||
fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create the report
|
||||
_, err = storage.CreateReportFromBackup(store, &report)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("restore completed with %d failures", failed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -25,16 +25,13 @@ import (
|
|||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/lmtp"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
"git.happydns.org/happyDeliver/pkg/analyzer"
|
||||
"git.happydns.org/happyDeliver/web"
|
||||
)
|
||||
|
||||
|
|
@ -66,11 +63,8 @@ func RunServer(cfg *config.Config) error {
|
|||
}
|
||||
}()
|
||||
|
||||
// Create analyzer adapter for API
|
||||
analyzerAdapter := analyzer.NewAPIAdapter(cfg)
|
||||
|
||||
// Create API handler
|
||||
handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
|
||||
handler := api.NewAPIHandler(store, cfg)
|
||||
|
||||
// Set up Gin router
|
||||
if os.Getenv("GIN_MODE") == "" {
|
||||
|
|
@ -78,30 +72,8 @@ func RunServer(cfg *config.Config) error {
|
|||
}
|
||||
router := gin.Default()
|
||||
|
||||
apiGroup := router.Group("/api")
|
||||
|
||||
if cfg.RateLimit > 0 {
|
||||
// Set up rate limiting (2x to handle burst)
|
||||
rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
|
||||
Rate: 2 * time.Second,
|
||||
Limit: 2 * cfg.RateLimit,
|
||||
})
|
||||
rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{
|
||||
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
|
||||
c.JSON(429, gin.H{
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(),
|
||||
})
|
||||
},
|
||||
KeyFunc: func(c *gin.Context) string {
|
||||
return c.ClientIP()
|
||||
},
|
||||
})
|
||||
|
||||
apiGroup.Use(rateLimiter)
|
||||
}
|
||||
|
||||
// Register API routes
|
||||
apiGroup := router.Group("/api")
|
||||
api.RegisterHandlers(apiGroup, handler)
|
||||
web.DeclareRoutes(cfg, router)
|
||||
|
||||
|
|
|
|||
|
|
@ -37,11 +37,7 @@ func declareFlags(o *Config) {
|
|||
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
||||
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
||||
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
|
||||
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
|
||||
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
||||
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import (
|
|||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
|
@ -42,9 +41,6 @@ type Config struct {
|
|||
Email EmailConfig
|
||||
Analysis AnalysisConfig
|
||||
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
||||
RateLimit uint // API rate limit (requests per second per IP)
|
||||
SurveyURL url.URL // URL for user feedback survey
|
||||
CustomLogoURL string // URL for custom logo image in the web UI
|
||||
}
|
||||
|
||||
// DatabaseConfig contains database connection settings
|
||||
|
|
@ -62,11 +58,9 @@ type EmailConfig struct {
|
|||
|
||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||
type AnalysisConfig struct {
|
||||
DNSTimeout time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
RBLs []string
|
||||
DNSWLs []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
DNSTimeout time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
RBLs []string
|
||||
}
|
||||
|
||||
// DefaultConfig returns a configuration with sensible defaults
|
||||
|
|
@ -75,7 +69,6 @@ func DefaultConfig() *Config {
|
|||
DevProxy: "",
|
||||
Bind: ":8080",
|
||||
ReportRetention: 0, // Keep reports forever by default
|
||||
RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default)
|
||||
Database: DatabaseConfig{
|
||||
Type: "sqlite",
|
||||
DSN: "happydeliver.db",
|
||||
|
|
@ -89,8 +82,6 @@ func DefaultConfig() *Config {
|
|||
DNSTimeout: 5 * time.Second,
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
RBLs: []string{},
|
||||
DNSWLs: []string{},
|
||||
CheckAllIPs: false, // By default, only check the first IP
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -44,25 +43,3 @@ func (i *StringArray) Set(value string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
type URL struct {
|
||||
URL *url.URL
|
||||
}
|
||||
|
||||
func (i *URL) String() string {
|
||||
if i.URL != nil {
|
||||
return i.URL.String()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (i *URL) Set(value string) error {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*i.URL = *u
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,6 @@ func (s *Session) Data(r io.Reader) error {
|
|||
|
||||
log.Printf("LMTP: Received %d bytes", len(emailData))
|
||||
|
||||
// Prepend Return-Path header from envelope sender
|
||||
returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from)
|
||||
emailData = append([]byte(returnPath), emailData...)
|
||||
|
||||
// Process email for each recipient
|
||||
// LMTP requires per-recipient status, but go-smtp handles this internally
|
||||
for _, recipient := range s.recipients {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
package receiver
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -96,7 +95,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
|||
return fmt.Errorf("failed to analyze email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
||||
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
|
||||
|
||||
// Marshal report to JSON
|
||||
reportJSON, err := json.Marshal(result.Report)
|
||||
|
|
@ -113,34 +112,8 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
|||
return nil
|
||||
}
|
||||
|
||||
// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
|
||||
// Hyphens are ignored during decoding
|
||||
func base32ToUUID(encoded string) (uuid.UUID, error) {
|
||||
// Remove hyphens for decoding
|
||||
encoded = strings.ReplaceAll(encoded, "-", "")
|
||||
|
||||
// Convert to uppercase for Base32 decoding
|
||||
encoded = strings.ToUpper(encoded)
|
||||
|
||||
// Decode from Base32
|
||||
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have exactly 16 bytes for UUID
|
||||
if len(decoded) != 16 {
|
||||
return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
|
||||
}
|
||||
|
||||
// Convert bytes to UUID
|
||||
var id uuid.UUID
|
||||
copy(id[:], decoded)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// extractTestID extracts the UUID from the test email address
|
||||
// Expected format: test-<base32-uuid>@domain.com
|
||||
// Expected format: test-<uuid>@domain.com
|
||||
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
|
||||
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
|
||||
email = strings.Trim(email, "<>")
|
||||
|
|
@ -160,10 +133,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
|
|||
|
||||
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
|
||||
|
||||
// Decode Base32 to UUID
|
||||
testID, err := base32ToUUID(uuidStr)
|
||||
// Parse UUID
|
||||
testID, err := uuid.Parse(uuidStr)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
|
||||
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
|
||||
}
|
||||
|
||||
return testID, nil
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ type Storage interface {
|
|||
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
|
||||
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
|
||||
ReportExists(testID uuid.UUID) (bool, error)
|
||||
UpdateReport(testID uuid.UUID, reportJSON []byte) error
|
||||
DeleteOldReports(olderThan time.Time) (int64, error)
|
||||
|
||||
// Close closes the database connection
|
||||
|
|
@ -108,7 +107,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
|
|||
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
|
||||
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
|
||||
var dbReport Report
|
||||
if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil {
|
||||
if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
|
|
@ -118,18 +117,6 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
|
|||
return dbReport.ReportJSON, dbReport.RawEmail, nil
|
||||
}
|
||||
|
||||
// UpdateReport updates the report JSON for an existing test ID
|
||||
func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error {
|
||||
result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to update report: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOldReports deletes reports older than the specified time
|
||||
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
|
||||
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
|
||||
|
|
@ -147,33 +134,3 @@ func (s *DBStorage) Close() error {
|
|||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// GetAllReports retrieves all reports from the database
|
||||
func GetAllReports(s Storage) ([]Report, error) {
|
||||
dbStorage, ok := s.(*DBStorage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("storage type does not support GetAllReports")
|
||||
}
|
||||
|
||||
var reports []Report
|
||||
if err := dbStorage.db.Find(&reports).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve reports: %w", err)
|
||||
}
|
||||
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
// CreateReportFromBackup creates a report from backup data, preserving timestamps
|
||||
func CreateReportFromBackup(s Storage, report *Report) (*Report, error) {
|
||||
dbStorage, ok := s.(*DBStorage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("storage type does not support CreateReportFromBackup")
|
||||
}
|
||||
|
||||
// Use Create to insert the report with all fields including timestamps
|
||||
if err := dbStorage.db.Create(report).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create report from backup: %w", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding)
|
||||
// with hyphens every 7 characters for better readability
|
||||
func UUIDToBase32(id uuid.UUID) string {
|
||||
// Use RFC 4648 Base32 encoding (URL-safe)
|
||||
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:])
|
||||
// Convert to lowercase for better readability
|
||||
encoded = strings.ToLower(encoded)
|
||||
|
||||
// Insert hyphens every 7 characters
|
||||
var result strings.Builder
|
||||
for i, char := range encoded {
|
||||
if i > 0 && i%7 == 0 {
|
||||
result.WriteRune('-')
|
||||
}
|
||||
result.WriteRune(char)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// Base32ToUUID converts a base32-encoded string back to a UUID
|
||||
// Accepts strings with or without hyphens
|
||||
func Base32ToUUID(encoded string) (uuid.UUID, error) {
|
||||
// Remove hyphens
|
||||
encoded = strings.ReplaceAll(encoded, "-", "")
|
||||
// Convert to uppercase for decoding
|
||||
encoded = strings.ToUpper(encoded)
|
||||
|
||||
// Decode base32
|
||||
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have exactly 16 bytes for a UUID
|
||||
if len(decoded) != 16 {
|
||||
return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded))
|
||||
}
|
||||
|
||||
// Convert byte slice to UUID
|
||||
var id uuid.UUID
|
||||
copy(id[:], decoded)
|
||||
return id, nil
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package version
|
||||
|
||||
// Version is the application version. It can be set at build time using ldflags:
|
||||
// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3"
|
||||
var Version = "(custom build)"
|
||||
|
|
@ -23,7 +23,6 @@ package analyzer
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -44,8 +43,6 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
|||
cfg.Analysis.DNSTimeout,
|
||||
cfg.Analysis.HTTPTimeout,
|
||||
cfg.Analysis.RBLs,
|
||||
cfg.Analysis.DNSWLs,
|
||||
cfg.Analysis.CheckAllIPs,
|
||||
)
|
||||
|
||||
return &EmailAnalyzer{
|
||||
|
|
@ -81,68 +78,10 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A
|
|||
}, nil
|
||||
}
|
||||
|
||||
// APIAdapter adapts the EmailAnalyzer to work with the API package
|
||||
// This adapter implements the interface expected by the API handler
|
||||
type APIAdapter struct {
|
||||
analyzer *EmailAnalyzer
|
||||
}
|
||||
|
||||
// NewAPIAdapter creates a new API adapter for the email analyzer
|
||||
func NewAPIAdapter(cfg *config.Config) *APIAdapter {
|
||||
return &APIAdapter{
|
||||
analyzer: NewEmailAnalyzer(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// AnalyzeEmailBytes performs analysis and returns JSON bytes directly
|
||||
func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) {
|
||||
result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Marshal report to JSON
|
||||
reportJSON, err := json.Marshal(result.Report)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
|
||||
return reportJSON, nil
|
||||
}
|
||||
|
||||
// AnalyzeDomain performs DNS analysis for a domain and returns the results
|
||||
func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
|
||||
// Perform DNS analysis
|
||||
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
|
||||
|
||||
// Calculate score
|
||||
score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults)
|
||||
|
||||
return dnsResults, score, grade
|
||||
}
|
||||
|
||||
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
|
||||
// Check the IP against all configured RBLs
|
||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0, "", err
|
||||
}
|
||||
|
||||
// Calculate score using the existing function
|
||||
// Create a minimal RBLResults structure for scoring
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
||||
IPsChecked: []string{ip},
|
||||
ListedCount: listedCount,
|
||||
}
|
||||
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
|
||||
|
||||
// Check the IP against all configured DNSWLs (informational only)
|
||||
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
||||
if err != nil {
|
||||
whitelists = nil
|
||||
}
|
||||
|
||||
return checks, whitelists, listedCount, score, grade, nil
|
||||
// GetScoreSummaryText returns a human-readable score summary
|
||||
func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
|
||||
if result == nil || result.Results == nil {
|
||||
return ""
|
||||
}
|
||||
return a.generator.GetScoreSummaryText(result.Results)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
|
|
@ -50,6 +52,13 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
|||
results.Spf = a.parseLegacySPF(email)
|
||||
}
|
||||
|
||||
if results.Dkim == nil || len(*results.Dkim) == 0 {
|
||||
dkimResults := a.parseLegacyDKIM(email)
|
||||
if len(dkimResults) > 0 {
|
||||
results.Dkim = &dkimResults
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ARC headers if not already parsed from Authentication-Results
|
||||
if results.Arc == nil {
|
||||
results.Arc = a.parseARCHeaders(email)
|
||||
|
|
@ -117,64 +126,382 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
|||
results.Arc = a.parseARCResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse IPRev
|
||||
if strings.HasPrefix(part, "iprev=") {
|
||||
if results.Iprev == nil {
|
||||
results.Iprev = a.parseIPRevResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse x-google-dkim
|
||||
if strings.HasPrefix(part, "x-google-dkim=") {
|
||||
if results.XGoogleDkim == nil {
|
||||
results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse x-aligned-from
|
||||
if strings.HasPrefix(part, "x-aligned-from=") {
|
||||
if results.XAlignedFrom == nil {
|
||||
results.XAlignedFrom = a.parseXAlignedFromResult(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateAuthenticationScore calculates the authentication score from auth results
|
||||
// Returns a score from 0-100 where higher is better
|
||||
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) {
|
||||
if results == nil {
|
||||
return 0, ""
|
||||
// parseSPFResult parses SPF result from Authentication-Results
|
||||
// Example: spf=pass smtp.mailfrom=sender@example.com
|
||||
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`spf=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
score := 0
|
||||
|
||||
// IPRev (15 points)
|
||||
score += 15 * a.calculateIPRevScore(results) / 100
|
||||
|
||||
// SPF (25 points)
|
||||
score += 25 * a.calculateSPFScore(results) / 100
|
||||
|
||||
// DKIM (23 points)
|
||||
score += 23 * a.calculateDKIMScore(results) / 100
|
||||
|
||||
// X-Google-DKIM (optional) - penalty if failed
|
||||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
||||
|
||||
// X-Aligned-From
|
||||
score += 2 * a.calculateXAlignedFromScore(results) / 100
|
||||
|
||||
// DMARC (25 points)
|
||||
score += 25 * a.calculateDMARCScore(results) / 100
|
||||
|
||||
// BIMI (10 points)
|
||||
score += 10 * a.calculateBIMIScore(results) / 100
|
||||
|
||||
// Ensure score doesn't exceed 100
|
||||
if score > 100 {
|
||||
score = 100
|
||||
// Extract domain
|
||||
domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
email := matches[1]
|
||||
// Extract domain from email
|
||||
if idx := strings.Index(email, "@"); idx != -1 {
|
||||
domain := email[idx+1:]
|
||||
result.Domain = &domain
|
||||
}
|
||||
}
|
||||
|
||||
return score, ScoreToGrade(score)
|
||||
// Extract details
|
||||
if idx := strings.Index(part, "("); idx != -1 {
|
||||
endIdx := strings.Index(part[idx:], ")")
|
||||
if endIdx != -1 {
|
||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||
result.Details = &details
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseDKIMResult parses DKIM result from Authentication-Results
|
||||
// Example: dkim=pass header.d=example.com header.s=selector1
|
||||
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`dkim=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.d or d)
|
||||
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract selector (header.s or s)
|
||||
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
|
||||
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
selector := matches[1]
|
||||
result.Selector = &selector
|
||||
}
|
||||
|
||||
// Extract details
|
||||
if idx := strings.Index(part, "("); idx != -1 {
|
||||
endIdx := strings.Index(part[idx:], ")")
|
||||
if endIdx != -1 {
|
||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||
result.Details = &details
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseDMARCResult parses DMARC result from Authentication-Results
|
||||
// Example: dmarc=pass action=none header.from=example.com
|
||||
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`dmarc=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.from)
|
||||
domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract details (action, policy, etc.)
|
||||
var detailsParts []string
|
||||
actionRe := regexp.MustCompile(`action=([^\s;]+)`)
|
||||
if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1]))
|
||||
}
|
||||
|
||||
if len(detailsParts) > 0 {
|
||||
details := strings.Join(detailsParts, " ")
|
||||
result.Details = &details
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseBIMIResult parses BIMI result from Authentication-Results
|
||||
// Example: bimi=pass header.d=example.com header.selector=default
|
||||
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`bimi=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.d or d)
|
||||
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract selector (header.selector or selector)
|
||||
selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
|
||||
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
selector := matches[1]
|
||||
result.Selector = &selector
|
||||
}
|
||||
|
||||
// Extract details
|
||||
if idx := strings.Index(part, "("); idx != -1 {
|
||||
endIdx := strings.Index(part[idx:], ")")
|
||||
if endIdx != -1 {
|
||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||
result.Details = &details
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseARCResult parses ARC result from Authentication-Results
|
||||
// Example: arc=pass
|
||||
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
||||
result := &api.ARCResult{}
|
||||
|
||||
// Extract result (pass, fail, none)
|
||||
re := regexp.MustCompile(`arc=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.ARCResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract details
|
||||
if idx := strings.Index(part, "("); idx != -1 {
|
||||
endIdx := strings.Index(part[idx:], ")")
|
||||
if endIdx != -1 {
|
||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||
result.Details = &details
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseARCHeaders parses ARC headers from email message
|
||||
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
||||
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
||||
// Get all ARC-related headers
|
||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||
|
||||
// If no ARC headers present, return nil
|
||||
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.ARCResult{
|
||||
Result: api.ARCResultResultNone,
|
||||
}
|
||||
|
||||
// Count the ARC chain length (number of sets)
|
||||
chainLength := len(arcSeal)
|
||||
result.ChainLength = &chainLength
|
||||
|
||||
// Validate the ARC chain
|
||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||
result.ChainValid = &chainValid
|
||||
|
||||
// Determine overall result
|
||||
if chainLength == 0 {
|
||||
result.Result = api.ARCResultResultNone
|
||||
details := "No ARC chain present"
|
||||
result.Details = &details
|
||||
} else if !chainValid {
|
||||
result.Result = api.ARCResultResultFail
|
||||
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
||||
result.Details = &details
|
||||
} else {
|
||||
result.Result = api.ARCResultResultPass
|
||||
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
||||
result.Details = &details
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// enhanceARCResult enhances an existing ARC result with chain information
|
||||
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
||||
if arcResult == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get ARC headers
|
||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||
|
||||
// Set chain length if not already set
|
||||
if arcResult.ChainLength == nil {
|
||||
chainLength := len(arcSeal)
|
||||
arcResult.ChainLength = &chainLength
|
||||
}
|
||||
|
||||
// Validate chain if not already validated
|
||||
if arcResult.ChainValid == nil {
|
||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||
arcResult.ChainValid = &chainValid
|
||||
}
|
||||
}
|
||||
|
||||
// validateARCChain validates the ARC chain for completeness
|
||||
// Each instance should have all three headers with matching instance numbers
|
||||
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
|
||||
// All three header types should have the same count
|
||||
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(arcSeal) == 0 {
|
||||
return true // No ARC chain is technically valid
|
||||
}
|
||||
|
||||
// Extract instance numbers from each header type
|
||||
sealInstances := a.extractARCInstances(arcSeal)
|
||||
sigInstances := a.extractARCInstances(arcMessageSig)
|
||||
authInstances := a.extractARCInstances(arcAuthResults)
|
||||
|
||||
// Check that all instance numbers match and are sequential starting from 1
|
||||
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify instances are sequential from 1 to N
|
||||
for i := 1; i <= len(sealInstances); i++ {
|
||||
if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// extractARCInstances extracts instance numbers from ARC headers
|
||||
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
|
||||
var instances []int
|
||||
re := regexp.MustCompile(`i=(\d+)`)
|
||||
|
||||
for _, header := range headers {
|
||||
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
|
||||
var instance int
|
||||
fmt.Sscanf(matches[1], "%d", &instance)
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// contains checks if a slice contains an integer
|
||||
func contains(slice []int, val int) bool {
|
||||
for _, item := range slice {
|
||||
if item == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pluralize returns "y" or "ies" based on count
|
||||
func pluralize(count int) string {
|
||||
if count == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
||||
receivedSPF := email.Header.Get("Received-SPF")
|
||||
if receivedSPF == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (first word)
|
||||
parts := strings.Fields(receivedSPF)
|
||||
if len(parts) > 0 {
|
||||
resultStr := strings.ToLower(parts[0])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Try to extract domain
|
||||
domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
|
||||
email := matches[1]
|
||||
if idx := strings.Index(email, "@"); idx != -1 {
|
||||
domain := email[idx+1:]
|
||||
result.Domain = &domain
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header
|
||||
func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult {
|
||||
var results []api.AuthResult
|
||||
|
||||
// Get all DKIM-Signature headers
|
||||
dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")]
|
||||
for _, dkimHeader := range dkimHeaders {
|
||||
result := api.AuthResult{
|
||||
Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone
|
||||
}
|
||||
|
||||
// Extract domain (d=)
|
||||
domainRe := regexp.MustCompile(`d=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract selector (s=)
|
||||
selectorRe := regexp.MustCompile(`s=([^\s;]+)`)
|
||||
if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
|
||||
selector := matches[1]
|
||||
result.Selector = &selector
|
||||
}
|
||||
|
||||
details := "DKIM signature present (verification status unknown)"
|
||||
result.Details = &details
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// textprotoCanonical converts a header name to canonical form
|
||||
func textprotoCanonical(s string) string {
|
||||
// Simple implementation - capitalize each word
|
||||
words := strings.Split(s, "-")
|
||||
for i, word := range words {
|
||||
if len(word) > 0 {
|
||||
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
|
||||
}
|
||||
}
|
||||
return strings.Join(words, "-")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// textprotoCanonical converts a header name to canonical form
|
||||
func textprotoCanonical(s string) string {
|
||||
// Simple implementation - capitalize each word
|
||||
words := strings.Split(s, "-")
|
||||
for i, word := range words {
|
||||
if len(word) > 0 {
|
||||
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
|
||||
}
|
||||
}
|
||||
return strings.Join(words, "-")
|
||||
}
|
||||
|
||||
// pluralize returns "y" or "ies" based on count
|
||||
func pluralize(count int) string {
|
||||
if count == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
// parseARCResult parses ARC result from Authentication-Results
|
||||
// Example: arc=pass
|
||||
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
||||
result := &api.ARCResult{}
|
||||
|
||||
// Extract result (pass, fail, none)
|
||||
re := regexp.MustCompile(`arc=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.ARCResultResult(resultStr)
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseARCHeaders parses ARC headers from email message
|
||||
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
||||
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
||||
// Get all ARC-related headers
|
||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||
|
||||
// If no ARC headers present, return nil
|
||||
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.ARCResult{
|
||||
Result: api.ARCResultResultNone,
|
||||
}
|
||||
|
||||
// Count the ARC chain length (number of sets)
|
||||
chainLength := len(arcSeal)
|
||||
result.ChainLength = &chainLength
|
||||
|
||||
// Validate the ARC chain
|
||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||
result.ChainValid = &chainValid
|
||||
|
||||
// Determine overall result
|
||||
if chainLength == 0 {
|
||||
result.Result = api.ARCResultResultNone
|
||||
details := "No ARC chain present"
|
||||
result.Details = &details
|
||||
} else if !chainValid {
|
||||
result.Result = api.ARCResultResultFail
|
||||
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
||||
result.Details = &details
|
||||
} else {
|
||||
result.Result = api.ARCResultResultPass
|
||||
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
||||
result.Details = &details
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// enhanceARCResult enhances an existing ARC result with chain information
|
||||
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
||||
if arcResult == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get ARC headers
|
||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||
|
||||
// Set chain length if not already set
|
||||
if arcResult.ChainLength == nil {
|
||||
chainLength := len(arcSeal)
|
||||
arcResult.ChainLength = &chainLength
|
||||
}
|
||||
|
||||
// Validate chain if not already validated
|
||||
if arcResult.ChainValid == nil {
|
||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||
arcResult.ChainValid = &chainValid
|
||||
}
|
||||
}
|
||||
|
||||
// validateARCChain validates the ARC chain for completeness
|
||||
// Each instance should have all three headers with matching instance numbers
|
||||
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
|
||||
// All three header types should have the same count
|
||||
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(arcSeal) == 0 {
|
||||
return true // No ARC chain is technically valid
|
||||
}
|
||||
|
||||
// Extract instance numbers from each header type
|
||||
sealInstances := a.extractARCInstances(arcSeal)
|
||||
sigInstances := a.extractARCInstances(arcMessageSig)
|
||||
authInstances := a.extractARCInstances(arcAuthResults)
|
||||
|
||||
// Check that all instance numbers match and are sequential starting from 1
|
||||
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify instances are sequential from 1 to N
|
||||
for i := 1; i <= len(sealInstances); i++ {
|
||||
if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// extractARCInstances extracts instance numbers from ARC headers
|
||||
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
|
||||
var instances []int
|
||||
re := regexp.MustCompile(`i=(\d+)`)
|
||||
|
||||
for _, header := range headers {
|
||||
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
|
||||
var instance int
|
||||
fmt.Sscanf(matches[1], "%d", &instance)
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseARCResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.ARCResultResult
|
||||
}{
|
||||
{
|
||||
name: "ARC pass",
|
||||
part: "arc=pass",
|
||||
expectedResult: api.ARCResultResultPass,
|
||||
},
|
||||
{
|
||||
name: "ARC fail",
|
||||
part: "arc=fail",
|
||||
expectedResult: api.ARCResultResultFail,
|
||||
},
|
||||
{
|
||||
name: "ARC none",
|
||||
part: "arc=none",
|
||||
expectedResult: api.ARCResultResultNone,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseARCResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateARCChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
arcAuthResults []string
|
||||
arcMessageSig []string
|
||||
arcSeal []string
|
||||
expectedValid bool
|
||||
}{
|
||||
{
|
||||
name: "Empty chain is valid",
|
||||
arcAuthResults: []string{},
|
||||
arcMessageSig: []string{},
|
||||
arcSeal: []string{},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Valid chain with single hop",
|
||||
arcAuthResults: []string{
|
||||
"i=1; example.com; spf=pass",
|
||||
},
|
||||
arcMessageSig: []string{
|
||||
"i=1; a=rsa-sha256; d=example.com",
|
||||
},
|
||||
arcSeal: []string{
|
||||
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Valid chain with two hops",
|
||||
arcAuthResults: []string{
|
||||
"i=1; example.com; spf=pass",
|
||||
"i=2; relay.com; arc=pass",
|
||||
},
|
||||
arcMessageSig: []string{
|
||||
"i=1; a=rsa-sha256; d=example.com",
|
||||
"i=2; a=rsa-sha256; d=relay.com",
|
||||
},
|
||||
arcSeal: []string{
|
||||
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
||||
"i=2; a=rsa-sha256; s=arc; d=relay.com",
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid chain - missing one header type",
|
||||
arcAuthResults: []string{
|
||||
"i=1; example.com; spf=pass",
|
||||
},
|
||||
arcMessageSig: []string{
|
||||
"i=1; a=rsa-sha256; d=example.com",
|
||||
},
|
||||
arcSeal: []string{},
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid chain - non-sequential instances",
|
||||
arcAuthResults: []string{
|
||||
"i=1; example.com; spf=pass",
|
||||
"i=3; relay.com; arc=pass",
|
||||
},
|
||||
arcMessageSig: []string{
|
||||
"i=1; a=rsa-sha256; d=example.com",
|
||||
"i=3; a=rsa-sha256; d=relay.com",
|
||||
},
|
||||
arcSeal: []string{
|
||||
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
||||
"i=3; a=rsa-sha256; s=arc; d=relay.com",
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal)
|
||||
|
||||
if valid != tt.expectedValid {
|
||||
t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseBIMIResult parses BIMI result from Authentication-Results
|
||||
// Example: bimi=pass header.d=example.com header.selector=default
|
||||
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`bimi=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.d or d)
|
||||
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract selector (header.selector or selector)
|
||||
selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
|
||||
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
selector := matches[1]
|
||||
result.Selector = &selector
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
|
||||
if results.Bimi != nil {
|
||||
switch results.Bimi.Result {
|
||||
case api.AuthResultResultPass:
|
||||
return 100
|
||||
case api.AuthResultResultDeclined:
|
||||
return 59
|
||||
default: // fail
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseBIMIResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDomain string
|
||||
expectedSelector string
|
||||
}{
|
||||
{
|
||||
name: "BIMI pass with domain and selector",
|
||||
part: "bimi=pass header.d=example.com header.selector=default",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "example.com",
|
||||
expectedSelector: "default",
|
||||
},
|
||||
{
|
||||
name: "BIMI fail",
|
||||
part: "bimi=fail header.d=example.com header.selector=default",
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDomain: "example.com",
|
||||
expectedSelector: "default",
|
||||
},
|
||||
{
|
||||
name: "BIMI with short form (d= and selector=)",
|
||||
part: "bimi=pass d=example.com selector=v1",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "example.com",
|
||||
expectedSelector: "v1",
|
||||
},
|
||||
{
|
||||
name: "BIMI none",
|
||||
part: "bimi=none header.d=example.com",
|
||||
expectedResult: api.AuthResultResultNone,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseBIMIResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||
var gotDomain string
|
||||
if result.Domain != nil {
|
||||
gotDomain = *result.Domain
|
||||
}
|
||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||
}
|
||||
if tt.expectedSelector != "" {
|
||||
if result.Selector == nil || *result.Selector != tt.expectedSelector {
|
||||
var gotSelector string
|
||||
if result.Selector != nil {
|
||||
gotSelector = *result.Selector
|
||||
}
|
||||
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
304
pkg/analyzer/authentication_checks.go
Normal file
304
pkg/analyzer/authentication_checks.go
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// GenerateAuthenticationChecks generates check results for authentication
|
||||
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
// SPF check
|
||||
if results.Spf != nil {
|
||||
check := a.generateSPFCheck(results.Spf)
|
||||
checks = append(checks, check)
|
||||
} else {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "SPF Record",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No SPF authentication result found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
|
||||
})
|
||||
}
|
||||
|
||||
// DKIM check
|
||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||
for i, dkim := range *results.Dkim {
|
||||
check := a.generateDKIMCheck(&dkim, i)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
} else {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "DKIM Signature",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No DKIM signature found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
|
||||
})
|
||||
}
|
||||
|
||||
// DMARC check
|
||||
if results.Dmarc != nil {
|
||||
check := a.generateDMARCCheck(results.Dmarc)
|
||||
checks = append(checks, check)
|
||||
} else {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "DMARC Policy",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No DMARC authentication result found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||
})
|
||||
}
|
||||
|
||||
// BIMI check (optional, informational only)
|
||||
if results.Bimi != nil {
|
||||
check := a.generateBIMICheck(results.Bimi)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
// ARC check (optional, for forwarded emails)
|
||||
if results.Arc != nil {
|
||||
check := a.generateARCCheck(results.Arc)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "SPF Record",
|
||||
}
|
||||
|
||||
switch spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "SPF validation passed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = "SPF validation failed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
||||
case api.AuthResultResultSoftfail:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.5
|
||||
check.Message = "SPF validation softfail"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||
case api.AuthResultResultNeutral:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.5
|
||||
check.Message = "SPF validation neutral"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
||||
default:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||
}
|
||||
|
||||
if spf.Domain != nil {
|
||||
details := fmt.Sprintf("Domain: %s", *spf.Domain)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
|
||||
}
|
||||
|
||||
switch dkim.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "DKIM signature is valid"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = "DKIM signature validation failed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
||||
default:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
||||
}
|
||||
|
||||
var detailsParts []string
|
||||
if dkim.Domain != nil {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
|
||||
}
|
||||
if dkim.Selector != nil {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
|
||||
}
|
||||
if len(detailsParts) > 0 {
|
||||
details := strings.Join(detailsParts, ", ")
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "DMARC Policy",
|
||||
}
|
||||
|
||||
switch dmarc.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "DMARC validation passed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = "DMARC validation failed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
||||
default:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
||||
}
|
||||
|
||||
if dmarc.Domain != nil {
|
||||
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "BIMI (Brand Indicators)",
|
||||
}
|
||||
|
||||
switch bimi.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
|
||||
check.Message = "BIMI validation passed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = "BIMI validation failed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
|
||||
default:
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
|
||||
}
|
||||
|
||||
if bimi.Domain != nil {
|
||||
details := fmt.Sprintf("Domain: %s", *bimi.Domain)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "ARC (Authenticated Received Chain)",
|
||||
}
|
||||
|
||||
switch arc.Result {
|
||||
case api.ARCResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding)
|
||||
check.Message = "ARC chain validation passed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
|
||||
case api.ARCResultResultFail:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = "ARC chain validation failed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
|
||||
default:
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = "No ARC chain present"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
|
||||
}
|
||||
|
||||
// Build details
|
||||
var detailsParts []string
|
||||
if arc.ChainLength != nil {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
|
||||
}
|
||||
if arc.ChainValid != nil {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
|
||||
}
|
||||
if arc.Details != nil {
|
||||
detailsParts = append(detailsParts, *arc.Details)
|
||||
}
|
||||
|
||||
if len(detailsParts) > 0 {
|
||||
details := strings.Join(detailsParts, ", ")
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseDKIMResult parses DKIM result from Authentication-Results
|
||||
// Example: dkim=pass header.d=example.com header.s=selector1
|
||||
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`dkim=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.d or d)
|
||||
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract selector (header.s or s)
|
||||
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
|
||||
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
selector := matches[1]
|
||||
result.Selector = &selector
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
|
||||
// Expect at least one passing signature
|
||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||
hasPass := false
|
||||
hasNonPass := false
|
||||
for _, dkim := range *results.Dkim {
|
||||
if dkim.Result == api.AuthResultResultPass {
|
||||
hasPass = true
|
||||
} else {
|
||||
hasNonPass = true
|
||||
}
|
||||
}
|
||||
if hasPass && hasNonPass {
|
||||
// Could be better
|
||||
return 90
|
||||
} else if hasPass {
|
||||
return 100
|
||||
} else {
|
||||
// Has DKIM signatures but none passed
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseDKIMResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDomain string
|
||||
expectedSelector string
|
||||
}{
|
||||
{
|
||||
name: "DKIM pass with domain and selector",
|
||||
part: "dkim=pass header.d=example.com header.s=default",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "example.com",
|
||||
expectedSelector: "default",
|
||||
},
|
||||
{
|
||||
name: "DKIM fail",
|
||||
part: "dkim=fail header.d=example.com header.s=selector1",
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDomain: "example.com",
|
||||
expectedSelector: "selector1",
|
||||
},
|
||||
{
|
||||
name: "DKIM with short form (d= and s=)",
|
||||
part: "dkim=pass d=example.com s=default",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "example.com",
|
||||
expectedSelector: "default",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseDKIMResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||
var gotDomain string
|
||||
if result.Domain != nil {
|
||||
gotDomain = *result.Domain
|
||||
}
|
||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||
}
|
||||
if result.Selector == nil || *result.Selector != tt.expectedSelector {
|
||||
var gotSelector string
|
||||
if result.Selector != nil {
|
||||
gotSelector = *result.Selector
|
||||
}
|
||||
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseDMARCResult parses DMARC result from Authentication-Results
|
||||
// Example: dmarc=pass action=none header.from=example.com
|
||||
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`dmarc=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.from)
|
||||
domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
|
||||
if results.Dmarc != nil {
|
||||
switch results.Dmarc.Result {
|
||||
case api.AuthResultResultPass:
|
||||
return 100
|
||||
case api.AuthResultResultNone:
|
||||
return 33
|
||||
default: // fail
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseDMARCResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDomain string
|
||||
}{
|
||||
{
|
||||
name: "DMARC pass",
|
||||
part: "dmarc=pass action=none header.from=example.com",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "DMARC fail",
|
||||
part: "dmarc=fail action=quarantine header.from=example.com",
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseDMARCResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||
var gotDomain string
|
||||
if result.Domain != nil {
|
||||
gotDomain = *result.Domain
|
||||
}
|
||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
|
||||
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
|
||||
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
|
||||
result := &api.IPRevResult{}
|
||||
|
||||
// Extract result (pass, fail, temperror, permerror, none)
|
||||
re := regexp.MustCompile(`iprev=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.IPRevResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract IP address (smtp.remote-ip or remote-ip)
|
||||
ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`)
|
||||
if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
ip := matches[1]
|
||||
result.Ip = &ip
|
||||
}
|
||||
|
||||
// Extract hostname from parentheses
|
||||
hostnameRe := regexp.MustCompile(`\(([^)]+)\)`)
|
||||
if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
hostname := matches[1]
|
||||
result.Hostname = &hostname
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
|
||||
if results.Iprev != nil {
|
||||
switch results.Iprev.Result {
|
||||
case api.Pass:
|
||||
return 100
|
||||
default: // fail, temperror, permerror
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseIPRevResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.IPRevResultResult
|
||||
expectedIP *string
|
||||
expectedHostname *string
|
||||
}{
|
||||
{
|
||||
name: "IPRev pass with IP and hostname",
|
||||
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("195.110.101.58"),
|
||||
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
||||
},
|
||||
{
|
||||
name: "IPRev pass without smtp prefix",
|
||||
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("192.0.2.1"),
|
||||
expectedHostname: api.PtrTo("mail.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev fail",
|
||||
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
|
||||
expectedResult: api.Fail,
|
||||
expectedIP: api.PtrTo("198.51.100.42"),
|
||||
expectedHostname: api.PtrTo("unknown.host.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev temperror",
|
||||
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
|
||||
expectedResult: api.Temperror,
|
||||
expectedIP: api.PtrTo("203.0.113.1"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
{
|
||||
name: "IPRev permerror",
|
||||
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
|
||||
expectedResult: api.Permerror,
|
||||
expectedIP: api.PtrTo("192.0.2.100"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
{
|
||||
name: "IPRev with IPv6",
|
||||
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("2001:db8::1"),
|
||||
expectedHostname: api.PtrTo("ipv6.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev with subdomain hostname",
|
||||
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("192.0.2.50"),
|
||||
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev pass without parentheses",
|
||||
part: "iprev=pass smtp.remote-ip=192.0.2.200",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("192.0.2.200"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseIPRevResult(tt.part)
|
||||
|
||||
// Check result
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
|
||||
// Check IP
|
||||
if tt.expectedIP != nil {
|
||||
if result.Ip == nil {
|
||||
t.Errorf("IP = nil, want %v", *tt.expectedIP)
|
||||
} else if *result.Ip != *tt.expectedIP {
|
||||
t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP)
|
||||
}
|
||||
} else {
|
||||
if result.Ip != nil {
|
||||
t.Errorf("IP = %v, want nil", *result.Ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Check hostname
|
||||
if tt.expectedHostname != nil {
|
||||
if result.Hostname == nil {
|
||||
t.Errorf("Hostname = nil, want %v", *tt.expectedHostname)
|
||||
} else if *result.Hostname != *tt.expectedHostname {
|
||||
t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname)
|
||||
}
|
||||
} else {
|
||||
if result.Hostname != nil {
|
||||
t.Errorf("Hostname = %v, want nil", *result.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
// Check details
|
||||
if result.Details == nil {
|
||||
t.Error("Expected Details to be set, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedIPRevResult *api.IPRevResultResult
|
||||
expectedIP *string
|
||||
expectedHostname *string
|
||||
}{
|
||||
{
|
||||
name: "IPRev pass in Authentication-Results",
|
||||
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||
expectedIP: api.PtrTo("195.110.101.58"),
|
||||
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
||||
},
|
||||
{
|
||||
name: "IPRev with other authentication methods",
|
||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
|
||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||
expectedIP: api.PtrTo("192.0.2.1"),
|
||||
expectedHostname: api.PtrTo("mail.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev fail",
|
||||
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
|
||||
expectedIPRevResult: api.PtrTo(api.Fail),
|
||||
expectedIP: api.PtrTo("198.51.100.42"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
{
|
||||
name: "No IPRev in header",
|
||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com",
|
||||
expectedIPRevResult: nil,
|
||||
},
|
||||
{
|
||||
name: "Multiple IPRev results - only first is parsed",
|
||||
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
|
||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||
expectedIP: api.PtrTo("192.0.2.1"),
|
||||
expectedHostname: api.PtrTo("first.com"),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||
|
||||
// Check IPRev
|
||||
if tt.expectedIPRevResult != nil {
|
||||
if results.Iprev == nil {
|
||||
t.Errorf("Expected IPRev result, got nil")
|
||||
} else {
|
||||
if results.Iprev.Result != *tt.expectedIPRevResult {
|
||||
t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult)
|
||||
}
|
||||
if tt.expectedIP != nil {
|
||||
if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP {
|
||||
var gotIP string
|
||||
if results.Iprev.Ip != nil {
|
||||
gotIP = *results.Iprev.Ip
|
||||
}
|
||||
t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP)
|
||||
}
|
||||
}
|
||||
if tt.expectedHostname != nil {
|
||||
if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname {
|
||||
var gotHostname string
|
||||
if results.Iprev.Hostname != nil {
|
||||
gotHostname = *results.Iprev.Hostname
|
||||
}
|
||||
t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if results.Iprev != nil {
|
||||
t.Errorf("Expected no IPRev result, got %+v", results.Iprev)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseSPFResult parses SPF result from Authentication-Results
|
||||
// Example: spf=pass smtp.mailfrom=sender@example.com
|
||||
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`spf=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain
|
||||
domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
email := matches[1]
|
||||
// Extract domain from email
|
||||
if idx := strings.Index(email, "@"); idx != -1 {
|
||||
domain := email[idx+1:]
|
||||
result.Domain = &domain
|
||||
}
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
||||
receivedSPF := email.Header.Get("Received-SPF")
|
||||
if receivedSPF == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (first word)
|
||||
parts := strings.Fields(receivedSPF)
|
||||
if len(parts) > 0 {
|
||||
resultStr := strings.ToLower(parts[0])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
result.Details = &receivedSPF
|
||||
|
||||
// Try to extract domain
|
||||
domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
|
||||
email := matches[1]
|
||||
if idx := strings.Index(email, "@"); idx != -1 {
|
||||
domain := email[idx+1:]
|
||||
result.Domain = &domain
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
|
||||
if results.Spf != nil {
|
||||
switch results.Spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
return 100
|
||||
case api.AuthResultResultNeutral, api.AuthResultResultNone:
|
||||
return 50
|
||||
case api.AuthResultResultSoftfail:
|
||||
return 17
|
||||
default: // fail, temperror, permerror
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseSPFResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDomain string
|
||||
}{
|
||||
{
|
||||
name: "SPF pass with domain",
|
||||
part: "spf=pass smtp.mailfrom=sender@example.com",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "SPF fail",
|
||||
part: "spf=fail smtp.mailfrom=sender@example.com",
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "SPF neutral",
|
||||
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
||||
expectedResult: api.AuthResultResultNeutral,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "SPF softfail",
|
||||
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
||||
expectedResult: api.AuthResultResultSoftfail,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseSPFResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||
var gotDomain string
|
||||
if result.Domain != nil {
|
||||
gotDomain = *result.Domain
|
||||
}
|
||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLegacySPF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
receivedSPF string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDomain *string
|
||||
expectNil bool
|
||||
}{
|
||||
{
|
||||
name: "SPF pass with envelope-from",
|
||||
receivedSPF: `pass
|
||||
(mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched))
|
||||
receiver=mx.receiver.com;
|
||||
identity=mailfrom;
|
||||
envelope-from="user@example.com";
|
||||
helo=smtp.example.com;
|
||||
client-ip=192.0.2.10`,
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: api.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "SPF fail with sender",
|
||||
receivedSPF: `fail
|
||||
(mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender)
|
||||
receiver=mx.receiver.com;
|
||||
identity=mailfrom;
|
||||
sender="sender@test.com";
|
||||
helo=smtp.test.com;
|
||||
client-ip=192.0.2.20`,
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDomain: api.PtrTo("test.com"),
|
||||
},
|
||||
{
|
||||
name: "SPF softfail",
|
||||
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
|
||||
expectedResult: api.AuthResultResultSoftfail,
|
||||
expectedDomain: api.PtrTo("example.org"),
|
||||
},
|
||||
{
|
||||
name: "SPF neutral",
|
||||
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
|
||||
expectedResult: api.AuthResultResultNeutral,
|
||||
expectedDomain: api.PtrTo("domain.net"),
|
||||
},
|
||||
{
|
||||
name: "SPF none",
|
||||
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
|
||||
expectedResult: api.AuthResultResultNone,
|
||||
expectedDomain: api.PtrTo("company.io"),
|
||||
},
|
||||
{
|
||||
name: "SPF temperror",
|
||||
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
|
||||
expectedResult: api.AuthResultResultTemperror,
|
||||
expectedDomain: api.PtrTo("shop.example"),
|
||||
},
|
||||
{
|
||||
name: "SPF permerror",
|
||||
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
|
||||
expectedResult: api.AuthResultResultPermerror,
|
||||
expectedDomain: api.PtrTo("invalid.test"),
|
||||
},
|
||||
{
|
||||
name: "SPF pass without domain extraction",
|
||||
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: nil,
|
||||
},
|
||||
{
|
||||
name: "Empty Received-SPF header",
|
||||
receivedSPF: "",
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "SPF with unquoted envelope-from",
|
||||
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: api.PtrTo("mail.example.net"),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a mock email message with Received-SPF header
|
||||
email := &EmailMessage{
|
||||
Header: make(map[string][]string),
|
||||
}
|
||||
if tt.receivedSPF != "" {
|
||||
email.Header["Received-Spf"] = []string{tt.receivedSPF}
|
||||
}
|
||||
|
||||
result := analyzer.parseLegacySPF(email)
|
||||
|
||||
if tt.expectNil {
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil result, got %+v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result, got nil")
|
||||
}
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
|
||||
if tt.expectedDomain != nil {
|
||||
if result.Domain == nil {
|
||||
t.Errorf("Domain = nil, want %v", *tt.expectedDomain)
|
||||
} else if *result.Domain != *tt.expectedDomain {
|
||||
t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain)
|
||||
}
|
||||
} else {
|
||||
if result.Domain != nil {
|
||||
t.Errorf("Domain = %v, want nil", *result.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Details == nil {
|
||||
t.Error("Expected Details to be set, got nil")
|
||||
} else if *result.Details != tt.receivedSPF {
|
||||
t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,65 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
|
||||
// Example: x-aligned-from=pass (Address match)
|
||||
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract details (everything after the result)
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
|
||||
if results.XAlignedFrom != nil {
|
||||
switch results.XAlignedFrom.Result {
|
||||
case api.AuthResultResultPass:
|
||||
// pass: positive contribution
|
||||
return 100
|
||||
case api.AuthResultResultFail:
|
||||
// fail: negative contribution
|
||||
return 0
|
||||
default:
|
||||
// neutral, none, etc.: no impact
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseXAlignedFromResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDetail string
|
||||
}{
|
||||
{
|
||||
name: "x-aligned-from pass with details",
|
||||
part: "x-aligned-from=pass (Address match)",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDetail: "pass (Address match)",
|
||||
},
|
||||
{
|
||||
name: "x-aligned-from fail with reason",
|
||||
part: "x-aligned-from=fail (Address mismatch)",
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDetail: "fail (Address mismatch)",
|
||||
},
|
||||
{
|
||||
name: "x-aligned-from pass minimal",
|
||||
part: "x-aligned-from=pass",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDetail: "pass",
|
||||
},
|
||||
{
|
||||
name: "x-aligned-from neutral",
|
||||
part: "x-aligned-from=neutral (No alignment check performed)",
|
||||
expectedResult: api.AuthResultResultNeutral,
|
||||
expectedDetail: "neutral (No alignment check performed)",
|
||||
},
|
||||
{
|
||||
name: "x-aligned-from none",
|
||||
part: "x-aligned-from=none",
|
||||
expectedResult: api.AuthResultResultNone,
|
||||
expectedDetail: "none",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseXAlignedFromResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
|
||||
if result.Details == nil {
|
||||
t.Errorf("Details = nil, want %v", tt.expectedDetail)
|
||||
} else if *result.Details != tt.expectedDetail {
|
||||
t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *api.AuthResult
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "pass result gives positive score",
|
||||
result: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "fail result gives zero score",
|
||||
result: &api.AuthResult{
|
||||
Result: api.AuthResultResultFail,
|
||||
},
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "neutral result gives zero score",
|
||||
result: &api.AuthResult{
|
||||
Result: api.AuthResultResultNeutral,
|
||||
},
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "none result gives zero score",
|
||||
result: &api.AuthResult{
|
||||
Result: api.AuthResultResultNone,
|
||||
},
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "nil result gives zero score",
|
||||
result: nil,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := &api.AuthenticationResults{
|
||||
XAlignedFrom: tt.result,
|
||||
}
|
||||
|
||||
score := analyzer.calculateXAlignedFromScore(results)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
|
||||
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
|
||||
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult {
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, etc.)
|
||||
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.AuthResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract domain (header.d or d)
|
||||
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
domain := matches[1]
|
||||
result.Domain = &domain
|
||||
}
|
||||
|
||||
// Extract selector (header.s or s) - though not always present in x-google-dkim
|
||||
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
|
||||
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
selector := matches[1]
|
||||
result.Selector = &selector
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
|
||||
if results.XGoogleDkim != nil {
|
||||
switch results.XGoogleDkim.Result {
|
||||
case api.AuthResultResultPass:
|
||||
// pass: don't alter the score
|
||||
default: // fail
|
||||
return -100
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseXGoogleDKIMResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.AuthResultResult
|
||||
expectedDomain string
|
||||
expectedSelector string
|
||||
}{
|
||||
{
|
||||
name: "x-google-dkim pass with domain",
|
||||
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "1e100.net",
|
||||
},
|
||||
{
|
||||
name: "x-google-dkim pass with short form",
|
||||
part: "x-google-dkim=pass d=gmail.com",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
expectedDomain: "gmail.com",
|
||||
},
|
||||
{
|
||||
name: "x-google-dkim fail",
|
||||
part: "x-google-dkim=fail header.d=example.com",
|
||||
expectedResult: api.AuthResultResultFail,
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "x-google-dkim with minimal info",
|
||||
part: "x-google-dkim=pass",
|
||||
expectedResult: api.AuthResultResultPass,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseXGoogleDKIMResult(tt.part)
|
||||
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
if tt.expectedDomain != "" {
|
||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||
var gotDomain string
|
||||
if result.Domain != nil {
|
||||
gotDomain = *result.Domain
|
||||
}
|
||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
|
|
@ -76,17 +77,17 @@ func TestExtractTextFromHTML(t *testing.T) {
|
|||
{
|
||||
name: "Multiple elements",
|
||||
html: "<div><h1>Title</h1><p>Paragraph</p></div>",
|
||||
expectedText: "Title Paragraph",
|
||||
expectedText: "TitleParagraph",
|
||||
},
|
||||
{
|
||||
name: "With script tag",
|
||||
html: "<p>Text</p><script>alert('hi')</script><p>More</p>",
|
||||
expectedText: "Text More",
|
||||
expectedText: "TextMore",
|
||||
},
|
||||
{
|
||||
name: "With style tag",
|
||||
html: "<p>Text</p><style>.class { color: red; }</style><p>More</p>",
|
||||
expectedText: "Text More",
|
||||
expectedText: "TextMore",
|
||||
},
|
||||
{
|
||||
name: "Empty HTML",
|
||||
|
|
@ -144,74 +145,6 @@ func TestIsUnsubscribeLink(t *testing.T) {
|
|||
linkText: "Read more",
|
||||
expected: false,
|
||||
},
|
||||
// Multilingual keyword detection - URL path
|
||||
{
|
||||
name: "German abmelden in URL",
|
||||
href: "https://example.com/abmelden?id=42",
|
||||
linkText: "Click here",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
|
||||
href: "https://example.com/se-desabonner?id=42",
|
||||
linkText: "Click here",
|
||||
expected: false,
|
||||
},
|
||||
// Multilingual keyword detection - link text
|
||||
{
|
||||
name: "German Abmelden in link text",
|
||||
href: "https://example.com/manage?id=42&lang=de",
|
||||
linkText: "Abmelden",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "French Se désabonner in link text",
|
||||
href: "https://example.com/manage?id=42&lang=fr",
|
||||
linkText: "Se désabonner",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Russian Отписаться in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ru",
|
||||
linkText: "Отписаться",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Chinese 退订 in link text",
|
||||
href: "https://example.com/manage?id=42&lang=zh",
|
||||
linkText: "退订",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Japanese 登録を取り消す in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ja",
|
||||
linkText: "登録を取り消す",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Korean 구독 해지 in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ko",
|
||||
linkText: "구독 해지",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Dutch Uitschrijven in link text",
|
||||
href: "https://example.com/manage?id=42&lang=nl",
|
||||
linkText: "Uitschrijven",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Polish Odsubskrybuj in link text",
|
||||
href: "https://example.com/manage?id=42&lang=pl",
|
||||
linkText: "Odsubskrybuj",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Turkish Üyeliği sonlandır in link text",
|
||||
href: "https://example.com/manage?id=42&lang=tr",
|
||||
linkText: "Üyeliği sonlandır",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
|
@ -281,16 +214,6 @@ func TestIsSuspiciousURL(t *testing.T) {
|
|||
url: "https://mail.example.com/page",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Mailto with @ symbol",
|
||||
url: "mailto:support@example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Mailto with multiple @ symbols",
|
||||
url: "mailto:user@subdomain@example.com",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
|
@ -685,6 +608,453 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateHTMLValidityCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Valid HTML",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0.2,
|
||||
},
|
||||
{
|
||||
name: "Invalid HTML",
|
||||
results: &ContentResults{
|
||||
HTMLValid: false,
|
||||
HTMLErrors: []string{"Parse error"},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateHTMLValidityCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLinkChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "All links valid",
|
||||
results: &ContentResults{
|
||||
Links: []LinkCheck{
|
||||
{URL: "https://example.com", Valid: true, Status: 200},
|
||||
{URL: "https://example.org", Valid: true, Status: 200},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0.4,
|
||||
},
|
||||
{
|
||||
name: "Broken links",
|
||||
results: &ContentResults{
|
||||
Links: []LinkCheck{
|
||||
{URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Links with warnings",
|
||||
results: &ContentResults{
|
||||
Links: []LinkCheck{
|
||||
{URL: "https://example.com", Valid: true, Warning: "Could not verify"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0.3,
|
||||
},
|
||||
{
|
||||
name: "No links",
|
||||
results: &ContentResults{},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.generateLinkChecks(tt.results)
|
||||
|
||||
if tt.name == "No links" {
|
||||
if len(checks) != 0 {
|
||||
t.Errorf("Expected no checks, got %d", len(checks))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(checks) == 0 {
|
||||
t.Fatal("Expected at least one check")
|
||||
}
|
||||
|
||||
check := checks[0]
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "All images have alt",
|
||||
results: &ContentResults{
|
||||
Images: []ImageCheck{
|
||||
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
|
||||
{Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "No images have alt",
|
||||
results: &ContentResults{
|
||||
Images: []ImageCheck{
|
||||
{Src: "img1.jpg", HasAlt: false},
|
||||
{Src: "img2.jpg", HasAlt: false},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
{
|
||||
name: "Some images have alt",
|
||||
results: &ContentResults{
|
||||
Images: []ImageCheck{
|
||||
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
|
||||
{Src: "img2.jpg", HasAlt: false},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.generateImageChecks(tt.results)
|
||||
|
||||
if len(checks) == 0 {
|
||||
t.Fatal("Expected at least one check")
|
||||
}
|
||||
|
||||
check := checks[0]
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUnsubscribeCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "Has unsubscribe link",
|
||||
results: &ContentResults{
|
||||
HasUnsubscribe: true,
|
||||
UnsubscribeLinks: []string{"https://example.com/unsubscribe"},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "No unsubscribe link",
|
||||
results: &ContentResults{},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateUnsubscribeCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTextConsistencyCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "High consistency",
|
||||
results: &ContentResults{
|
||||
TextPlainRatio: 0.8,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "Low consistency",
|
||||
results: &ContentResults{
|
||||
TextPlainRatio: 0.1,
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateTextConsistencyCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageRatioCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "Reasonable ratio",
|
||||
results: &ContentResults{
|
||||
ImageTextRatio: 3.0,
|
||||
Images: []ImageCheck{{}, {}, {}},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "High ratio",
|
||||
results: &ContentResults{
|
||||
ImageTextRatio: 7.0,
|
||||
Images: make([]ImageCheck, 7),
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
{
|
||||
name: "Excessive ratio",
|
||||
results: &ContentResults{
|
||||
ImageTextRatio: 15.0,
|
||||
Images: make([]ImageCheck, 15),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateImageRatioCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSuspiciousURLCheck(t *testing.T) {
|
||||
results := &ContentResults{
|
||||
SuspiciousURLs: []string{
|
||||
"https://bit.ly/abc123",
|
||||
"https://192.168.1.1/page",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
check := analyzer.generateSuspiciousURLCheck(results)
|
||||
|
||||
if check.Status != api.CheckStatusWarn {
|
||||
t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
if !strings.Contains(check.Message, "2") {
|
||||
t.Error("Message should mention the count of suspicious URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
minScore float32
|
||||
maxScore float32
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minScore: 0.0,
|
||||
maxScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Perfect content",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: true,
|
||||
TextPlainRatio: 0.8,
|
||||
ImageTextRatio: 3.0,
|
||||
},
|
||||
minScore: 1.8,
|
||||
maxScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "Poor content",
|
||||
results: &ContentResults{
|
||||
HTMLValid: false,
|
||||
Links: []LinkCheck{{Valid: true, Status: 404}},
|
||||
Images: []ImageCheck{{HasAlt: false}},
|
||||
HasUnsubscribe: false,
|
||||
TextPlainRatio: 0.1,
|
||||
ImageTextRatio: 15.0,
|
||||
SuspiciousURLs: []string{"url1", "url2"},
|
||||
},
|
||||
minScore: 0.0,
|
||||
maxScore: 0.5,
|
||||
},
|
||||
{
|
||||
name: "Average content",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: false,
|
||||
TextPlainRatio: 0.5,
|
||||
ImageTextRatio: 4.0,
|
||||
},
|
||||
minScore: 1.0,
|
||||
maxScore: 1.8,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := analyzer.GetContentScore(tt.results)
|
||||
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
|
||||
}
|
||||
|
||||
// Ensure score is capped at 2.0
|
||||
if score > 2.0 {
|
||||
t.Errorf("Score %v exceeds maximum of 2.0", score)
|
||||
}
|
||||
|
||||
// Ensure score is not negative
|
||||
if score < 0.0 {
|
||||
t.Errorf("Score %v is negative", score)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateContentChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "Complete results",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: true,
|
||||
TextContent: "Plain text",
|
||||
HTMLContent: "<p>HTML text</p>",
|
||||
ImageTextRatio: 3.0,
|
||||
},
|
||||
minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio
|
||||
},
|
||||
{
|
||||
name: "With suspicious URLs",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
SuspiciousURLs: []string{"url1"},
|
||||
},
|
||||
minChecks: 3, // HTML, Unsubscribe, Suspicious URLs
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateContentChecks(tt.results)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the Content category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for testing
|
||||
|
||||
func parseHTML(htmlStr string) (*html.Node, error) {
|
||||
|
|
@ -706,276 +1076,3 @@ func findFirstLink(n *html.Node) *html.Node {
|
|||
func parseURL(urlStr string) (*url.URL, error) {
|
||||
return url.Parse(urlStr)
|
||||
}
|
||||
|
||||
func TestHasDomainMisalignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
linkText string
|
||||
expected bool
|
||||
reason string
|
||||
}{
|
||||
// Phishing cases - should return true
|
||||
{
|
||||
name: "Obvious phishing - different domains",
|
||||
href: "https://evil.com/page",
|
||||
linkText: "Click here to verify your paypal.com account",
|
||||
expected: true,
|
||||
reason: "Link text shows 'paypal.com' but URL points to 'evil.com'",
|
||||
},
|
||||
{
|
||||
name: "Domain in link text differs from URL",
|
||||
href: "http://attacker.net",
|
||||
linkText: "Visit google.com for more info",
|
||||
expected: true,
|
||||
reason: "Link text shows 'google.com' but URL points to 'attacker.net'",
|
||||
},
|
||||
{
|
||||
name: "URL shown in text differs from actual URL",
|
||||
href: "https://phishing-site.xyz/login",
|
||||
linkText: "https://www.bank.example.com/secure",
|
||||
expected: true,
|
||||
reason: "Full URL in text doesn't match actual destination",
|
||||
},
|
||||
{
|
||||
name: "Similar but different domain",
|
||||
href: "https://paypa1.com/login",
|
||||
linkText: "Login to your paypal.com account",
|
||||
expected: true,
|
||||
reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'",
|
||||
},
|
||||
{
|
||||
name: "Subdomain spoofing",
|
||||
href: "https://paypal.com.evil.com/login",
|
||||
linkText: "Verify your paypal.com account",
|
||||
expected: true,
|
||||
reason: "Domain is 'evil.com', not 'paypal.com'",
|
||||
},
|
||||
{
|
||||
name: "Multiple domains in text, none match",
|
||||
href: "https://badsite.com",
|
||||
linkText: "Transfer from bank.com to paypal.com",
|
||||
expected: true,
|
||||
reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'",
|
||||
},
|
||||
|
||||
// Legitimate cases - should return false
|
||||
{
|
||||
name: "Exact domain match",
|
||||
href: "https://example.com/page",
|
||||
linkText: "Visit example.com for more information",
|
||||
expected: false,
|
||||
reason: "Domains match exactly",
|
||||
},
|
||||
{
|
||||
name: "Legitimate subdomain",
|
||||
href: "https://mail.google.com/inbox",
|
||||
linkText: "Check your google.com email",
|
||||
expected: false,
|
||||
reason: "Subdomain of the mentioned domain",
|
||||
},
|
||||
{
|
||||
name: "www prefix variation",
|
||||
href: "https://www.example.com/page",
|
||||
linkText: "Visit example.com",
|
||||
expected: false,
|
||||
reason: "www prefix is acceptable variation",
|
||||
},
|
||||
{
|
||||
name: "Generic link text - click here",
|
||||
href: "https://anywhere.com",
|
||||
linkText: "click here",
|
||||
expected: false,
|
||||
reason: "Generic text doesn't contain a domain",
|
||||
},
|
||||
{
|
||||
name: "Generic link text - read more",
|
||||
href: "https://example.com/article",
|
||||
linkText: "Read more",
|
||||
expected: false,
|
||||
reason: "Generic text doesn't contain a domain",
|
||||
},
|
||||
{
|
||||
name: "Generic link text - learn more",
|
||||
href: "https://example.com/info",
|
||||
linkText: "Learn More",
|
||||
expected: false,
|
||||
reason: "Generic text doesn't contain a domain (case insensitive)",
|
||||
},
|
||||
{
|
||||
name: "No domain in link text",
|
||||
href: "https://example.com/page",
|
||||
linkText: "Click to continue",
|
||||
expected: false,
|
||||
reason: "Link text has no domain reference",
|
||||
},
|
||||
{
|
||||
name: "Short link text",
|
||||
href: "https://example.com",
|
||||
linkText: "Go",
|
||||
expected: false,
|
||||
reason: "Text too short to contain meaningful domain",
|
||||
},
|
||||
{
|
||||
name: "Empty link text",
|
||||
href: "https://example.com",
|
||||
linkText: "",
|
||||
expected: false,
|
||||
reason: "Empty text cannot contain domain",
|
||||
},
|
||||
{
|
||||
name: "Mailto link - matching domain",
|
||||
href: "mailto:support@example.com",
|
||||
linkText: "Email support@example.com",
|
||||
expected: false,
|
||||
reason: "Mailto email matches text email",
|
||||
},
|
||||
{
|
||||
name: "Mailto link - domain mismatch (phishing)",
|
||||
href: "mailto:attacker@evil.com",
|
||||
linkText: "Contact support@paypal.com for help",
|
||||
expected: true,
|
||||
reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'",
|
||||
},
|
||||
{
|
||||
name: "Mailto link - generic text",
|
||||
href: "mailto:info@example.com",
|
||||
linkText: "Contact us",
|
||||
expected: false,
|
||||
reason: "Generic text without domain reference",
|
||||
},
|
||||
{
|
||||
name: "Mailto link - same domain different user",
|
||||
href: "mailto:sales@example.com",
|
||||
linkText: "Email support@example.com",
|
||||
expected: false,
|
||||
reason: "Both emails share the same domain",
|
||||
},
|
||||
{
|
||||
name: "Mailto link - text shows only domain",
|
||||
href: "mailto:info@example.com",
|
||||
linkText: "Write to example.com",
|
||||
expected: false,
|
||||
reason: "Text domain matches mailto domain",
|
||||
},
|
||||
{
|
||||
name: "Mailto link - domain in text doesn't match",
|
||||
href: "mailto:scam@phishing.net",
|
||||
linkText: "Reply to customer-service@amazon.com",
|
||||
expected: true,
|
||||
reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text",
|
||||
},
|
||||
{
|
||||
name: "Tel link",
|
||||
href: "tel:+1234567890",
|
||||
linkText: "Call example.com support",
|
||||
expected: false,
|
||||
reason: "Non-HTTP(S) links are excluded",
|
||||
},
|
||||
{
|
||||
name: "Same base domain with different subdomains",
|
||||
href: "https://www.example.com/page",
|
||||
linkText: "Visit blog.example.com",
|
||||
expected: false,
|
||||
reason: "Both share same base domain 'example.com'",
|
||||
},
|
||||
{
|
||||
name: "URL with path matches domain in text",
|
||||
href: "https://example.com/section/page",
|
||||
linkText: "Go to example.com",
|
||||
expected: false,
|
||||
reason: "Domain matches, path doesn't matter",
|
||||
},
|
||||
{
|
||||
name: "Generic text - subscribe",
|
||||
href: "https://newsletter.example.com/signup",
|
||||
linkText: "Subscribe",
|
||||
expected: false,
|
||||
reason: "Generic call-to-action text",
|
||||
},
|
||||
{
|
||||
name: "Generic text - unsubscribe",
|
||||
href: "https://example.com/unsubscribe?id=123",
|
||||
linkText: "Unsubscribe",
|
||||
expected: false,
|
||||
reason: "Generic unsubscribe text",
|
||||
},
|
||||
{
|
||||
name: "Generic text - download",
|
||||
href: "https://files.example.com/document.pdf",
|
||||
linkText: "Download",
|
||||
expected: false,
|
||||
reason: "Generic action text",
|
||||
},
|
||||
{
|
||||
name: "Descriptive text without domain",
|
||||
href: "https://shop.example.com/products",
|
||||
linkText: "View our latest products",
|
||||
expected: false,
|
||||
reason: "No domain mentioned in text",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "Domain-like text but not valid domain",
|
||||
href: "https://example.com",
|
||||
linkText: "Save up to 50.00 dollars",
|
||||
expected: false,
|
||||
reason: "50.00 looks like domain but isn't",
|
||||
},
|
||||
{
|
||||
name: "Text with http prefix matching domain",
|
||||
href: "https://example.com/page",
|
||||
linkText: "Visit http://example.com",
|
||||
expected: false,
|
||||
reason: "Domains match despite different protocols in display",
|
||||
},
|
||||
{
|
||||
name: "Port in URL should not affect matching",
|
||||
href: "https://example.com:8080/page",
|
||||
linkText: "Go to example.com",
|
||||
expected: false,
|
||||
reason: "Port number doesn't affect domain matching",
|
||||
},
|
||||
{
|
||||
name: "Whitespace in link text",
|
||||
href: "https://example.com",
|
||||
linkText: " example.com ",
|
||||
expected: false,
|
||||
reason: "Whitespace should be trimmed",
|
||||
},
|
||||
{
|
||||
name: "Multiple spaces in generic text",
|
||||
href: "https://example.com",
|
||||
linkText: "click here",
|
||||
expected: false,
|
||||
reason: "Generic text with extra spaces",
|
||||
},
|
||||
{
|
||||
name: "Anchor fragment in URL",
|
||||
href: "https://example.com/page#section",
|
||||
linkText: "example.com section",
|
||||
expected: false,
|
||||
reason: "Fragment doesn't affect domain matching",
|
||||
},
|
||||
{
|
||||
name: "Query parameters in URL",
|
||||
href: "https://example.com/page?utm_source=email",
|
||||
linkText: "Visit example.com",
|
||||
expected: false,
|
||||
reason: "Query params don't affect domain matching",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.hasDomainMisalignment(tt.href, tt.linkText)
|
||||
if result != tt.expected {
|
||||
t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s",
|
||||
tt.href, tt.linkText, result, tt.expected, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
|
|
@ -30,212 +35,685 @@ import (
|
|||
// DNSAnalyzer analyzes DNS records for email domains
|
||||
type DNSAnalyzer struct {
|
||||
Timeout time.Duration
|
||||
resolver DNSResolver
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
|
||||
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
|
||||
return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver())
|
||||
}
|
||||
|
||||
// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver.
|
||||
// If resolver is nil, a StandardDNSResolver will be used.
|
||||
func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer {
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second // Default timeout
|
||||
}
|
||||
if resolver == nil {
|
||||
resolver = NewStandardDNSResolver()
|
||||
}
|
||||
return &DNSAnalyzer{
|
||||
Timeout: timeout,
|
||||
resolver: resolver,
|
||||
Timeout: timeout,
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DNSResults represents DNS validation results for an email
|
||||
type DNSResults struct {
|
||||
Domain string
|
||||
MXRecords []MXRecord
|
||||
SPFRecord *SPFRecord
|
||||
DKIMRecords []DKIMRecord
|
||||
DMARCRecord *DMARCRecord
|
||||
BIMIRecord *BIMIRecord
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// MXRecord represents an MX record
|
||||
type MXRecord struct {
|
||||
Host string
|
||||
Priority uint16
|
||||
Valid bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// SPFRecord represents an SPF record
|
||||
type SPFRecord struct {
|
||||
Record string
|
||||
Valid bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// DKIMRecord represents a DKIM record
|
||||
type DKIMRecord struct {
|
||||
Selector string
|
||||
Domain string
|
||||
Record string
|
||||
Valid bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// DMARCRecord represents a DMARC record
|
||||
type DMARCRecord struct {
|
||||
Record string
|
||||
Policy string // none, quarantine, reject
|
||||
Valid bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// BIMIRecord represents a BIMI record
|
||||
type BIMIRecord struct {
|
||||
Selector string
|
||||
Domain string
|
||||
Record string
|
||||
LogoURL string // URL to the brand logo (SVG)
|
||||
VMCURL string // URL to Verified Mark Certificate (optional)
|
||||
Valid bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// AnalyzeDNS performs DNS validation for the email's domain
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
|
||||
// Extract domain from From address
|
||||
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
||||
return &api.DNSResults{
|
||||
Errors: &[]string{"Unable to extract domain from email"},
|
||||
}
|
||||
}
|
||||
fromDomain := *headersResults.DomainAlignment.FromDomain
|
||||
|
||||
results := &api.DNSResults{
|
||||
FromDomain: fromDomain,
|
||||
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
|
||||
}
|
||||
|
||||
// Determine which domain to check SPF for (Return-Path domain)
|
||||
// SPF validates the envelope sender (Return-Path), not the From header
|
||||
spfDomain := fromDomain
|
||||
if results.RpDomain != nil {
|
||||
spfDomain = *results.RpDomain
|
||||
}
|
||||
|
||||
// Store sender IP for later use in scoring
|
||||
var senderIP string
|
||||
if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 {
|
||||
firstHop := (*headersResults.ReceivedChain)[0]
|
||||
if firstHop.Ip != nil && *firstHop.Ip != "" {
|
||||
senderIP = *firstHop.Ip
|
||||
ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP)
|
||||
if len(ptrRecords) > 0 {
|
||||
results.PtrRecords = &ptrRecords
|
||||
}
|
||||
if len(forwardRecords) > 0 {
|
||||
results.PtrForwardRecords = &forwardRecords
|
||||
}
|
||||
domain := d.extractDomain(email)
|
||||
if domain == "" {
|
||||
return &DNSResults{
|
||||
Errors: []string{"Unable to extract domain from email"},
|
||||
}
|
||||
}
|
||||
|
||||
// Check MX records for From domain (where replies would go)
|
||||
results.FromMxRecords = d.checkMXRecords(fromDomain)
|
||||
|
||||
// Check MX records for Return-Path domain (where bounces would go)
|
||||
// Only check if Return-Path domain is different from From domain
|
||||
if results.RpDomain != nil && *results.RpDomain != fromDomain {
|
||||
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
|
||||
results := &DNSResults{
|
||||
Domain: domain,
|
||||
}
|
||||
|
||||
// Check SPF records (for Return-Path domain - this is the envelope sender)
|
||||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||
// Check MX records
|
||||
results.MXRecords = d.checkMXRecords(domain)
|
||||
|
||||
// Check SPF record
|
||||
results.SPFRecord = d.checkSPFRecord(domain)
|
||||
|
||||
// Check DKIM records (from authentication results)
|
||||
// DKIM can be for any domain, but typically the From domain
|
||||
if authResults != nil && authResults.Dkim != nil {
|
||||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && dkim.Selector != nil {
|
||||
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
||||
if dkimRecord != nil {
|
||||
if results.DkimRecords == nil {
|
||||
results.DkimRecords = new([]api.DKIMRecord)
|
||||
}
|
||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check DMARC record (for From domain - DMARC protects the visible sender)
|
||||
// DMARC validates alignment between SPF/DKIM and the From domain
|
||||
results.DmarcRecord = d.checkDMARCRecord(fromDomain)
|
||||
|
||||
// Check BIMI record (for From domain - branding is based on visible sender)
|
||||
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// AnalyzeDomainOnly performs DNS validation for a domain without email context
|
||||
// This is useful for checking domain configuration without sending an actual email
|
||||
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
|
||||
results := &api.DNSResults{
|
||||
FromDomain: domain,
|
||||
}
|
||||
|
||||
// Check MX records
|
||||
results.FromMxRecords = d.checkMXRecords(domain)
|
||||
|
||||
// Check SPF records
|
||||
results.SpfRecords = d.checkSPFRecords(domain)
|
||||
|
||||
// Check DMARC record
|
||||
results.DmarcRecord = d.checkDMARCRecord(domain)
|
||||
results.DMARCRecord = d.checkDMARCRecord(domain)
|
||||
|
||||
// Check BIMI record with default selector
|
||||
results.BimiRecord = d.checkBIMIRecord(domain, "default")
|
||||
// Check BIMI record (using default selector)
|
||||
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
|
||||
// Returns a score from 0-100 where higher is better
|
||||
// This version excludes PTR and DKIM checks since they require email context
|
||||
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) {
|
||||
if results == nil {
|
||||
return 0, ""
|
||||
// extractDomain extracts the domain from the email's From address
|
||||
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
|
||||
if email.From != nil && email.From.Address != "" {
|
||||
parts := strings.Split(email.From.Address, "@")
|
||||
if len(parts) == 2 {
|
||||
return strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
score := 0
|
||||
// checkMXRecords looks up MX records for a domain
|
||||
func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// MX Records: 30 points (only one domain to check)
|
||||
mxScore := d.calculateMXScore(results)
|
||||
// Since calculateMXScore checks both From and RP domains,
|
||||
// and we only have From domain, we use the full score
|
||||
score += 30 * mxScore / 100
|
||||
|
||||
// SPF Records: 30 points
|
||||
score += 30 * d.calculateSPFScore(results) / 100
|
||||
|
||||
// DMARC Record: 40 points
|
||||
score += 40 * d.calculateDMARCScore(results) / 100
|
||||
|
||||
// BIMI Record: only bonus
|
||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||
if score >= 100 {
|
||||
return 100, "A+"
|
||||
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
||||
if err != nil {
|
||||
return []MXRecord{
|
||||
{
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed maximum
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
// Ensure score is non-negative
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
return score, ScoreToGradeKind(score)
|
||||
}
|
||||
|
||||
// CalculateDNSScore calculates the DNS score from records results
|
||||
// Returns a score from 0-100 where higher is better
|
||||
// senderIP is the original sender IP address used for FCrDNS verification
|
||||
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) {
|
||||
if results == nil {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
score := 0
|
||||
|
||||
// PTR and Forward DNS: 20 points
|
||||
score += 20 * d.calculatePTRScore(results, senderIP) / 100
|
||||
|
||||
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
|
||||
score += 20 * d.calculateMXScore(results) / 100
|
||||
|
||||
// SPF Records: 20 points
|
||||
score += 20 * d.calculateSPFScore(results) / 100
|
||||
|
||||
// DKIM Records: 20 points
|
||||
score += 20 * d.calculateDKIMScore(results) / 100
|
||||
|
||||
// DMARC Record: 20 points
|
||||
score += 20 * d.calculateDMARCScore(results) / 100
|
||||
|
||||
// BIMI Record
|
||||
// BIMI is optional but indicates advanced email branding
|
||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||
if score >= 100 {
|
||||
return 100, "A+"
|
||||
if len(mxRecords) == 0 {
|
||||
return []MXRecord{
|
||||
{
|
||||
Valid: false,
|
||||
Error: "No MX records found",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed maximum
|
||||
if score > 100 {
|
||||
score = 100
|
||||
var results []MXRecord
|
||||
for _, mx := range mxRecords {
|
||||
results = append(results, MXRecord{
|
||||
Host: mx.Host,
|
||||
Priority: mx.Pref,
|
||||
Valid: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure score is non-negative
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
return score, ScoreToGrade(score)
|
||||
return results
|
||||
}
|
||||
|
||||
// checkSPFRecord looks up and validates SPF record for a domain
|
||||
func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
||||
if err != nil {
|
||||
return &SPFRecord{
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("Failed to lookup TXT records: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Find SPF record (starts with "v=spf1")
|
||||
var spfRecord string
|
||||
spfCount := 0
|
||||
for _, txt := range txtRecords {
|
||||
if strings.HasPrefix(txt, "v=spf1") {
|
||||
spfRecord = txt
|
||||
spfCount++
|
||||
}
|
||||
}
|
||||
|
||||
if spfCount == 0 {
|
||||
return &SPFRecord{
|
||||
Valid: false,
|
||||
Error: "No SPF record found",
|
||||
}
|
||||
}
|
||||
|
||||
if spfCount > 1 {
|
||||
return &SPFRecord{
|
||||
Record: spfRecord,
|
||||
Valid: false,
|
||||
Error: "Multiple SPF records found (RFC violation)",
|
||||
}
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if !d.validateSPF(spfRecord) {
|
||||
return &SPFRecord{
|
||||
Record: spfRecord,
|
||||
Valid: false,
|
||||
Error: "SPF record appears malformed",
|
||||
}
|
||||
}
|
||||
|
||||
return &SPFRecord{
|
||||
Record: spfRecord,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// validateSPF performs basic SPF record validation
|
||||
func (d *DNSAnalyzer) validateSPF(record string) bool {
|
||||
// Must start with v=spf1
|
||||
if !strings.HasPrefix(record, "v=spf1") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for common syntax issues
|
||||
// Should have a final mechanism (all, +all, -all, ~all, ?all)
|
||||
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
|
||||
hasValidEnding := false
|
||||
for _, ending := range validEndings {
|
||||
if strings.HasSuffix(record, ending) {
|
||||
hasValidEnding = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return hasValidEnding
|
||||
}
|
||||
|
||||
// checkDKIMRecord looks up and validates DKIM record for a domain and selector
|
||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord {
|
||||
// DKIM records are at: selector._domainkey.domain
|
||||
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
||||
if err != nil {
|
||||
return &DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if len(txtRecords) == 0 {
|
||||
return &DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: "No DKIM record found",
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate all TXT record parts (DKIM can be split)
|
||||
dkimRecord := strings.Join(txtRecords, "")
|
||||
|
||||
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
||||
if !d.validateDKIM(dkimRecord) {
|
||||
return &DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: dkimRecord,
|
||||
Valid: false,
|
||||
Error: "DKIM record appears malformed",
|
||||
}
|
||||
}
|
||||
|
||||
return &DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: dkimRecord,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// validateDKIM performs basic DKIM record validation
|
||||
func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
||||
// Should contain p= tag (public key)
|
||||
if !strings.Contains(record, "p=") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Often contains v=DKIM1 but not required
|
||||
// If v= is present, it should be DKIM1
|
||||
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// checkDMARCRecord looks up and validates DMARC record for a domain
|
||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord {
|
||||
// DMARC records are at: _dmarc.domain
|
||||
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
||||
if err != nil {
|
||||
return &DMARCRecord{
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Find DMARC record (starts with "v=DMARC1")
|
||||
var dmarcRecord string
|
||||
for _, txt := range txtRecords {
|
||||
if strings.HasPrefix(txt, "v=DMARC1") {
|
||||
dmarcRecord = txt
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dmarcRecord == "" {
|
||||
return &DMARCRecord{
|
||||
Valid: false,
|
||||
Error: "No DMARC record found",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract policy
|
||||
policy := d.extractDMARCPolicy(dmarcRecord)
|
||||
|
||||
// Basic validation
|
||||
if !d.validateDMARC(dmarcRecord) {
|
||||
return &DMARCRecord{
|
||||
Record: dmarcRecord,
|
||||
Policy: policy,
|
||||
Valid: false,
|
||||
Error: "DMARC record appears malformed",
|
||||
}
|
||||
}
|
||||
|
||||
return &DMARCRecord{
|
||||
Record: dmarcRecord,
|
||||
Policy: policy,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||
// Look for p=none, p=quarantine, or p=reject
|
||||
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// validateDMARC performs basic DMARC record validation
|
||||
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||
// Must start with v=DMARC1
|
||||
if !strings.HasPrefix(record, "v=DMARC1") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a policy tag
|
||||
if !strings.Contains(record, "p=") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
||||
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
|
||||
// BIMI records are at: selector._bimi.domain
|
||||
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
||||
if err != nil {
|
||||
return &BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if len(txtRecords) == 0 {
|
||||
return &BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: "No BIMI record found",
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate all TXT record parts (BIMI can be split)
|
||||
bimiRecord := strings.Join(txtRecords, "")
|
||||
|
||||
// Extract logo URL and VMC URL
|
||||
logoURL := d.extractBIMITag(bimiRecord, "l")
|
||||
vmcURL := d.extractBIMITag(bimiRecord, "a")
|
||||
|
||||
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
||||
if !d.validateBIMI(bimiRecord) {
|
||||
return &BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: bimiRecord,
|
||||
LogoURL: logoURL,
|
||||
VMCURL: vmcURL,
|
||||
Valid: false,
|
||||
Error: "BIMI record appears malformed",
|
||||
}
|
||||
}
|
||||
|
||||
return &BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: bimiRecord,
|
||||
LogoURL: logoURL,
|
||||
VMCURL: vmcURL,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// extractBIMITag extracts a tag value from a BIMI record
|
||||
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
|
||||
// Look for tag=value pattern
|
||||
re := regexp.MustCompile(tag + `=([^;]+)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateBIMI performs basic BIMI record validation
|
||||
func (d *DNSAnalyzer) validateBIMI(record string) bool {
|
||||
// Must start with v=BIMI1
|
||||
if !strings.HasPrefix(record, "v=BIMI1") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a logo URL tag (l=)
|
||||
if !strings.Contains(record, "l=") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateDNSChecks generates check results for DNS validation
|
||||
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if results == nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
// MX record check
|
||||
checks = append(checks, d.generateMXCheck(results))
|
||||
|
||||
// SPF record check
|
||||
if results.SPFRecord != nil {
|
||||
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
|
||||
}
|
||||
|
||||
// DKIM record checks
|
||||
for _, dkim := range results.DKIMRecords {
|
||||
checks = append(checks, d.generateDKIMCheck(&dkim))
|
||||
}
|
||||
|
||||
// DMARC record check
|
||||
if results.DMARCRecord != nil {
|
||||
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
|
||||
}
|
||||
|
||||
// BIMI record check (optional)
|
||||
if results.BIMIRecord != nil {
|
||||
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateMXCheck creates a check for MX records
|
||||
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "MX Records",
|
||||
}
|
||||
|
||||
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
|
||||
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
|
||||
check.Message = results.MXRecords[0].Error
|
||||
} else {
|
||||
check.Message = "No valid MX records found"
|
||||
}
|
||||
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
|
||||
|
||||
// Add details about MX records
|
||||
var mxList []string
|
||||
for _, mx := range results.MXRecords {
|
||||
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
|
||||
}
|
||||
details := strings.Join(mxList, ", ")
|
||||
check.Details = &details
|
||||
check.Advice = api.PtrTo("Your MX records are properly configured")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateSPFCheck creates a check for SPF records
|
||||
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "SPF Record",
|
||||
}
|
||||
|
||||
if !spf.Valid {
|
||||
// If no record exists at all, it's a failure
|
||||
if spf.Record == "" {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = spf.Error
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
|
||||
} else {
|
||||
// If record exists but is invalid, it's a warning
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.5
|
||||
check.Message = "SPF record found but appears invalid"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
|
||||
check.Details = &spf.Record
|
||||
}
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "Valid SPF record found"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Details = &spf.Record
|
||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateDKIMCheck creates a check for DKIM records
|
||||
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
|
||||
}
|
||||
|
||||
if !dkim.Valid {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
|
||||
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "Valid DKIM record found"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||
check.Details = &details
|
||||
check.Advice = api.PtrTo("Your DKIM record is properly published")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateDMARCCheck creates a check for DMARC records
|
||||
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "DMARC Record",
|
||||
}
|
||||
|
||||
if !dmarc.Valid {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = dmarc.Error
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Details = &dmarc.Record
|
||||
|
||||
// Provide advice based on policy
|
||||
switch dmarc.Policy {
|
||||
case "none":
|
||||
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
|
||||
check.Advice = &advice
|
||||
case "quarantine":
|
||||
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
|
||||
check.Advice = &advice
|
||||
case "reject":
|
||||
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
|
||||
check.Advice = &advice
|
||||
default:
|
||||
advice := "Your DMARC record is properly configured"
|
||||
check.Advice = &advice
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateBIMICheck creates a check for BIMI records
|
||||
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "BIMI Record",
|
||||
}
|
||||
|
||||
if !bimi.Valid {
|
||||
// BIMI is optional, so missing record is just informational
|
||||
if bimi.Record == "" {
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = "No BIMI record found (optional)"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
|
||||
} else {
|
||||
// If record exists but is invalid
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
|
||||
check.Details = &bimi.Record
|
||||
}
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
|
||||
check.Message = "Valid BIMI record found"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
|
||||
// Build details with logo and VMC URLs
|
||||
var detailsParts []string
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
|
||||
if bimi.LogoURL != "" {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
|
||||
}
|
||||
if bimi.VMCURL != "" {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
|
||||
check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
|
||||
} else {
|
||||
check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
|
||||
}
|
||||
|
||||
details := strings.Join(detailsParts, ", ")
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
||||
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
|
||||
// BIMI records are at: selector._bimi.domain
|
||||
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
||||
if err != nil {
|
||||
return &api.BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
|
||||
}
|
||||
}
|
||||
|
||||
if len(txtRecords) == 0 {
|
||||
return &api.BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("No BIMI record found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate all TXT record parts (BIMI can be split)
|
||||
bimiRecord := strings.Join(txtRecords, "")
|
||||
|
||||
// Extract logo URL and VMC URL
|
||||
logoURL := d.extractBIMITag(bimiRecord, "l")
|
||||
vmcURL := d.extractBIMITag(bimiRecord, "a")
|
||||
|
||||
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
||||
if !d.validateBIMI(bimiRecord) {
|
||||
return &api.BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: &bimiRecord,
|
||||
LogoUrl: &logoURL,
|
||||
VmcUrl: &vmcURL,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("BIMI record appears malformed"),
|
||||
}
|
||||
}
|
||||
|
||||
return &api.BIMIRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: &bimiRecord,
|
||||
LogoUrl: &logoURL,
|
||||
VmcUrl: &vmcURL,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// extractBIMITag extracts a tag value from a BIMI record
|
||||
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
|
||||
// Look for tag=value pattern
|
||||
re := regexp.MustCompile(tag + `=([^;]+)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateBIMI performs basic BIMI record validation
|
||||
func (d *DNSAnalyzer) validateBIMI(record string) bool {
|
||||
// Must start with v=BIMI1
|
||||
if !strings.HasPrefix(record, "v=BIMI1") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a logo URL tag (l=)
|
||||
if !strings.Contains(record, "l=") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractBIMITag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
tag string
|
||||
expectedValue string
|
||||
}{
|
||||
{
|
||||
name: "Extract logo URL (l tag)",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
tag: "l",
|
||||
expectedValue: "https://example.com/logo.svg",
|
||||
},
|
||||
{
|
||||
name: "Extract VMC URL (a tag)",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||
tag: "a",
|
||||
expectedValue: "https://example.com/vmc.pem",
|
||||
},
|
||||
{
|
||||
name: "Tag not found",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
tag: "a",
|
||||
expectedValue: "",
|
||||
},
|
||||
{
|
||||
name: "Tag with spaces",
|
||||
record: "v=BIMI1; l= https://example.com/logo.svg ",
|
||||
tag: "l",
|
||||
expectedValue: "https://example.com/logo.svg",
|
||||
},
|
||||
{
|
||||
name: "Empty record",
|
||||
record: "",
|
||||
tag: "l",
|
||||
expectedValue: "",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractBIMITag(tt.record, tt.tag)
|
||||
if result != tt.expectedValue {
|
||||
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBIMI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid BIMI with logo URL",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid BIMI with logo and VMC",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - no version",
|
||||
record: "l=https://example.com/logo.svg",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - wrong version",
|
||||
record: "v=BIMI2; l=https://example.com/logo.svg",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - no logo URL",
|
||||
record: "v=BIMI1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - empty",
|
||||
record: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateBIMI(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||
// DKIM records are at: selector._domainkey.domain
|
||||
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
||||
if err != nil {
|
||||
return &api.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
||||
}
|
||||
}
|
||||
|
||||
if len(txtRecords) == 0 {
|
||||
return &api.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("No DKIM record found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate all TXT record parts (DKIM can be split)
|
||||
dkimRecord := strings.Join(txtRecords, "")
|
||||
|
||||
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
||||
if !d.validateDKIM(dkimRecord) {
|
||||
return &api.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: api.PtrTo(dkimRecord),
|
||||
Valid: false,
|
||||
Error: api.PtrTo("DKIM record appears malformed"),
|
||||
}
|
||||
}
|
||||
|
||||
return &api.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: &dkimRecord,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// validateDKIM performs basic DKIM record validation
|
||||
func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
||||
// Should contain p= tag (public key)
|
||||
if !strings.Contains(record, "p=") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Often contains v=DKIM1 but not required
|
||||
// If v= is present, it should be DKIM1
|
||||
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) {
|
||||
// DKIM provides strong email authentication
|
||||
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
||||
hasValidDKIM := false
|
||||
for _, dkim := range *results.DkimRecords {
|
||||
if dkim.Valid {
|
||||
hasValidDKIM = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidDKIM {
|
||||
score += 100
|
||||
} else {
|
||||
// Partial credit if DKIM record exists but has issues
|
||||
score += 25
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateDKIM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid DKIM with version",
|
||||
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid DKIM without version",
|
||||
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM - no public key",
|
||||
record: "v=DKIM1; k=rsa",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM - wrong version",
|
||||
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM - empty",
|
||||
record: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateDKIM(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
|
||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
||||
// DMARC records are at: _dmarc.domain
|
||||
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
||||
if err != nil {
|
||||
return &api.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||
}
|
||||
}
|
||||
|
||||
// Find DMARC record (starts with "v=DMARC1")
|
||||
var dmarcRecord string
|
||||
for _, txt := range txtRecords {
|
||||
if strings.HasPrefix(txt, "v=DMARC1") {
|
||||
dmarcRecord = txt
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dmarcRecord == "" {
|
||||
return &api.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: api.PtrTo("No DMARC record found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Extract policy
|
||||
policy := d.extractDMARCPolicy(dmarcRecord)
|
||||
|
||||
// Extract subdomain policy
|
||||
subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord)
|
||||
|
||||
// Extract percentage
|
||||
percentage := d.extractDMARCPercentage(dmarcRecord)
|
||||
|
||||
// Extract alignment modes
|
||||
spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord)
|
||||
dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord)
|
||||
|
||||
// Basic validation
|
||||
if !d.validateDMARC(dmarcRecord) {
|
||||
return &api.DMARCRecord{
|
||||
Record: &dmarcRecord,
|
||||
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
||||
SubdomainPolicy: subdomainPolicy,
|
||||
Percentage: percentage,
|
||||
SpfAlignment: spfAlignment,
|
||||
DkimAlignment: dkimAlignment,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("DMARC record appears malformed"),
|
||||
}
|
||||
}
|
||||
|
||||
return &api.DMARCRecord{
|
||||
Record: &dmarcRecord,
|
||||
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
||||
SubdomainPolicy: subdomainPolicy,
|
||||
Percentage: percentage,
|
||||
SpfAlignment: spfAlignment,
|
||||
DkimAlignment: dkimAlignment,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||
// Look for p=none, p=quarantine, or p=reject
|
||||
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
|
||||
// Returns "relaxed" (default) or "strict"
|
||||
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment {
|
||||
// Look for aspf=s (strict) or aspf=r (relaxed)
|
||||
re := regexp.MustCompile(`aspf=(r|s)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
if matches[1] == "s" {
|
||||
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
|
||||
}
|
||||
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
||||
}
|
||||
// Default is relaxed if not specified
|
||||
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
||||
}
|
||||
|
||||
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
|
||||
// Returns "relaxed" (default) or "strict"
|
||||
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
|
||||
// Look for adkim=s (strict) or adkim=r (relaxed)
|
||||
re := regexp.MustCompile(`adkim=(r|s)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
if matches[1] == "s" {
|
||||
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
|
||||
}
|
||||
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
||||
}
|
||||
// Default is relaxed if not specified
|
||||
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
||||
}
|
||||
|
||||
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
|
||||
// Returns the sp tag value or nil if not specified (defaults to main policy)
|
||||
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
|
||||
// Look for sp=none, sp=quarantine, or sp=reject
|
||||
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
|
||||
}
|
||||
// If sp is not specified, it defaults to the main policy (p tag)
|
||||
// Return nil to indicate it's using the default
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDMARCPercentage extracts the percentage from a DMARC record
|
||||
// Returns the pct tag value or nil if not specified (defaults to 100)
|
||||
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
||||
// Look for pct=<number>
|
||||
re := regexp.MustCompile(`pct=(\d+)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
// Convert string to int
|
||||
var pct int
|
||||
fmt.Sscanf(matches[1], "%d", &pct)
|
||||
// Validate range (0-100)
|
||||
if pct >= 0 && pct <= 100 {
|
||||
return &pct
|
||||
}
|
||||
}
|
||||
// Default is 100 if not specified
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDMARC performs basic DMARC record validation
|
||||
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||
// Must start with v=DMARC1
|
||||
if !strings.HasPrefix(record, "v=DMARC1") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a policy tag
|
||||
if !strings.Contains(record, "p=") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
|
||||
// DMARC ties SPF and DKIM together and provides policy
|
||||
if results.DmarcRecord != nil {
|
||||
if results.DmarcRecord.Valid {
|
||||
score += 50
|
||||
// Bonus points for stricter policies
|
||||
if results.DmarcRecord.Policy != nil {
|
||||
switch *results.DmarcRecord.Policy {
|
||||
case "reject":
|
||||
// Strictest policy - full points already awarded
|
||||
score += 25
|
||||
case "quarantine":
|
||||
// Good policy - no deduction
|
||||
case "none":
|
||||
// Weakest policy - deduct 5 points
|
||||
score -= 25
|
||||
}
|
||||
}
|
||||
// Bonus points for strict alignment modes (2 points each)
|
||||
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict {
|
||||
score += 5
|
||||
}
|
||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict {
|
||||
score += 5
|
||||
}
|
||||
// Subdomain policy scoring (sp tag)
|
||||
// +3 for stricter or equal subdomain policy, -3 for weaker
|
||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||
mainPolicy := string(*results.DmarcRecord.Policy)
|
||||
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
||||
|
||||
// Policy strength: none < quarantine < reject
|
||||
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||
|
||||
mainStrength := policyStrength[mainPolicy]
|
||||
subStrength := policyStrength[subPolicy]
|
||||
|
||||
if subStrength >= mainStrength {
|
||||
// Subdomain policy is equal or stricter
|
||||
score += 15
|
||||
} else {
|
||||
// Subdomain policy is weaker
|
||||
score -= 15
|
||||
}
|
||||
} else {
|
||||
// No sp tag means subdomains inherit main policy (good default)
|
||||
score += 15
|
||||
}
|
||||
// Percentage scoring (pct tag)
|
||||
// Apply the percentage on the current score
|
||||
if results.DmarcRecord.Percentage != nil {
|
||||
pct := *results.DmarcRecord.Percentage
|
||||
|
||||
score = score * pct / 100
|
||||
}
|
||||
} else if results.DmarcRecord.Record != nil {
|
||||
// Partial credit if DMARC record exists but has issues
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestExtractDMARCPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedPolicy string
|
||||
}{
|
||||
{
|
||||
name: "Policy none",
|
||||
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
|
||||
expectedPolicy: "none",
|
||||
},
|
||||
{
|
||||
name: "Policy quarantine",
|
||||
record: "v=DMARC1; p=quarantine; pct=100",
|
||||
expectedPolicy: "quarantine",
|
||||
},
|
||||
{
|
||||
name: "Policy reject",
|
||||
record: "v=DMARC1; p=reject; sp=reject",
|
||||
expectedPolicy: "reject",
|
||||
},
|
||||
{
|
||||
name: "No policy",
|
||||
record: "v=DMARC1",
|
||||
expectedPolicy: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCPolicy(tt.record)
|
||||
if result != tt.expectedPolicy {
|
||||
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDMARC(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid DMARC",
|
||||
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid DMARC minimal",
|
||||
record: "v=DMARC1; p=none",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no version",
|
||||
record: "p=quarantine",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no policy",
|
||||
record: "v=DMARC1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - wrong version",
|
||||
record: "v=DMARC2; p=reject",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateDMARC(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCSPFAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedAlignment string
|
||||
}{
|
||||
{
|
||||
name: "SPF alignment - strict",
|
||||
record: "v=DMARC1; p=quarantine; aspf=s",
|
||||
expectedAlignment: "strict",
|
||||
},
|
||||
{
|
||||
name: "SPF alignment - relaxed (explicit)",
|
||||
record: "v=DMARC1; p=quarantine; aspf=r",
|
||||
expectedAlignment: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "SPF alignment - relaxed (default, not specified)",
|
||||
record: "v=DMARC1; p=quarantine",
|
||||
expectedAlignment: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "Both alignments specified - check SPF strict",
|
||||
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
|
||||
expectedAlignment: "strict",
|
||||
},
|
||||
{
|
||||
name: "Both alignments specified - check SPF relaxed",
|
||||
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
|
||||
expectedAlignment: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "Complex record with SPF strict",
|
||||
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
|
||||
expectedAlignment: "strict",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCSPFAlignment(tt.record)
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
|
||||
}
|
||||
if string(*result) != tt.expectedAlignment {
|
||||
t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCDKIMAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedAlignment string
|
||||
}{
|
||||
{
|
||||
name: "DKIM alignment - strict",
|
||||
record: "v=DMARC1; p=reject; adkim=s",
|
||||
expectedAlignment: "strict",
|
||||
},
|
||||
{
|
||||
name: "DKIM alignment - relaxed (explicit)",
|
||||
record: "v=DMARC1; p=reject; adkim=r",
|
||||
expectedAlignment: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "DKIM alignment - relaxed (default, not specified)",
|
||||
record: "v=DMARC1; p=none",
|
||||
expectedAlignment: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "Both alignments specified - check DKIM strict",
|
||||
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
|
||||
expectedAlignment: "strict",
|
||||
},
|
||||
{
|
||||
name: "Both alignments specified - check DKIM relaxed",
|
||||
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
|
||||
expectedAlignment: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "Complex record with DKIM strict",
|
||||
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100",
|
||||
expectedAlignment: "strict",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCDKIMAlignment(tt.record)
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record)
|
||||
}
|
||||
if string(*result) != tt.expectedAlignment {
|
||||
t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedPolicy *string
|
||||
}{
|
||||
{
|
||||
name: "Subdomain policy - none",
|
||||
record: "v=DMARC1; p=quarantine; sp=none",
|
||||
expectedPolicy: api.PtrTo("none"),
|
||||
},
|
||||
{
|
||||
name: "Subdomain policy - quarantine",
|
||||
record: "v=DMARC1; p=reject; sp=quarantine",
|
||||
expectedPolicy: api.PtrTo("quarantine"),
|
||||
},
|
||||
{
|
||||
name: "Subdomain policy - reject",
|
||||
record: "v=DMARC1; p=quarantine; sp=reject",
|
||||
expectedPolicy: api.PtrTo("reject"),
|
||||
},
|
||||
{
|
||||
name: "No subdomain policy specified (defaults to main policy)",
|
||||
record: "v=DMARC1; p=quarantine",
|
||||
expectedPolicy: nil,
|
||||
},
|
||||
{
|
||||
name: "Complex record with subdomain policy",
|
||||
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
|
||||
expectedPolicy: api.PtrTo("quarantine"),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCSubdomainPolicy(tt.record)
|
||||
if tt.expectedPolicy == nil {
|
||||
if result != nil {
|
||||
t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result)
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
|
||||
}
|
||||
if string(*result) != *tt.expectedPolicy {
|
||||
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCPercentage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedPercentage *int
|
||||
}{
|
||||
{
|
||||
name: "Percentage - 100",
|
||||
record: "v=DMARC1; p=quarantine; pct=100",
|
||||
expectedPercentage: api.PtrTo(100),
|
||||
},
|
||||
{
|
||||
name: "Percentage - 50",
|
||||
record: "v=DMARC1; p=quarantine; pct=50",
|
||||
expectedPercentage: api.PtrTo(50),
|
||||
},
|
||||
{
|
||||
name: "Percentage - 25",
|
||||
record: "v=DMARC1; p=reject; pct=25",
|
||||
expectedPercentage: api.PtrTo(25),
|
||||
},
|
||||
{
|
||||
name: "Percentage - 0",
|
||||
record: "v=DMARC1; p=none; pct=0",
|
||||
expectedPercentage: api.PtrTo(0),
|
||||
},
|
||||
{
|
||||
name: "No percentage specified (defaults to 100)",
|
||||
record: "v=DMARC1; p=quarantine",
|
||||
expectedPercentage: nil,
|
||||
},
|
||||
{
|
||||
name: "Complex record with percentage",
|
||||
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
|
||||
expectedPercentage: api.PtrTo(75),
|
||||
},
|
||||
{
|
||||
name: "Invalid percentage > 100 (ignored)",
|
||||
record: "v=DMARC1; p=quarantine; pct=150",
|
||||
expectedPercentage: nil,
|
||||
},
|
||||
{
|
||||
name: "Invalid percentage < 0 (ignored)",
|
||||
record: "v=DMARC1; p=quarantine; pct=-10",
|
||||
expectedPercentage: nil,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCPercentage(tt.record)
|
||||
if tt.expectedPercentage == nil {
|
||||
if result != nil {
|
||||
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage)
|
||||
}
|
||||
if *result != *tt.expectedPercentage {
|
||||
t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
|
||||
// Returns PTR hostnames and their corresponding forward-resolved IPs
|
||||
func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Perform reverse DNS lookup (PTR)
|
||||
ptrNames, err := d.resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(ptrNames) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var forwardIPs []string
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
// For each PTR record, perform forward DNS lookup (A/AAAA)
|
||||
for _, ptrName := range ptrNames {
|
||||
// Look up A records
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
aRecords, err := d.resolver.LookupHost(ctx, ptrName)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
for _, forwardIP := range aRecords {
|
||||
if !seenIPs[forwardIP] {
|
||||
forwardIPs = append(forwardIPs, forwardIP)
|
||||
seenIPs[forwardIP] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ptrNames, forwardIPs
|
||||
}
|
||||
|
||||
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
|
||||
func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) {
|
||||
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
|
||||
// 50 points for having PTR records
|
||||
score += 50
|
||||
|
||||
if len(*results.PtrRecords) > 1 {
|
||||
// Penalty has it's bad to have multiple PTR records
|
||||
score -= 15
|
||||
}
|
||||
|
||||
// Additional 50 points for forward-confirmed reverse DNS (FCrDNS)
|
||||
// This means the PTR hostname resolves back to IPs that include the original sender IP
|
||||
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {
|
||||
// Verify that the sender IP is in the list of forward-resolved IPs
|
||||
fcrDnsValid := false
|
||||
for _, forwardIP := range *results.PtrForwardRecords {
|
||||
if forwardIP == senderIP {
|
||||
fcrDnsValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if fcrDnsValid {
|
||||
score += 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// checkMXRecords looks up MX records for a domain
|
||||
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
||||
if err != nil {
|
||||
return &[]api.MXRecord{
|
||||
{
|
||||
Valid: false,
|
||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if len(mxRecords) == 0 {
|
||||
return &[]api.MXRecord{
|
||||
{
|
||||
Valid: false,
|
||||
Error: api.PtrTo("No MX records found"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var results []api.MXRecord
|
||||
for _, mx := range mxRecords {
|
||||
results = append(results, api.MXRecord{
|
||||
Host: mx.Host,
|
||||
Priority: mx.Pref,
|
||||
Valid: true,
|
||||
})
|
||||
}
|
||||
|
||||
return &results
|
||||
}
|
||||
|
||||
func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) {
|
||||
// Having valid MX records is critical for email deliverability
|
||||
// From domain MX records (half points) - needed for replies
|
||||
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||
hasValidFromMX := false
|
||||
for _, mx := range *results.FromMxRecords {
|
||||
if mx.Valid {
|
||||
hasValidFromMX = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidFromMX {
|
||||
score += 50
|
||||
}
|
||||
}
|
||||
|
||||
// Return-Path domain MX records (10 points) - needed for bounces
|
||||
if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 {
|
||||
hasValidRpMX := false
|
||||
for _, mx := range *results.RpMxRecords {
|
||||
if mx.Valid {
|
||||
hasValidRpMX = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidRpMX {
|
||||
score += 50
|
||||
}
|
||||
} else if results.RpDomain != nil && *results.RpDomain != results.FromDomain {
|
||||
// If Return-Path domain is different but has no MX records, it's a problem
|
||||
// Don't deduct points if RP domain is same as From domain (already checked)
|
||||
} else {
|
||||
// If Return-Path is same as From domain, give full 10 points for RP MX
|
||||
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||
hasValidFromMX := false
|
||||
for _, mx := range *results.FromMxRecords {
|
||||
if mx.Valid {
|
||||
hasValidFromMX = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidFromMX {
|
||||
score += 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// DNSResolver defines the interface for DNS resolution operations.
|
||||
// This interface abstracts DNS lookups to allow for custom implementations,
|
||||
// such as mock resolvers for testing or caching resolvers for performance.
|
||||
type DNSResolver interface {
|
||||
// LookupMX returns the DNS MX records for the given domain.
|
||||
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
|
||||
|
||||
// LookupTXT returns the DNS TXT records for the given domain.
|
||||
LookupTXT(ctx context.Context, name string) ([]string, error)
|
||||
|
||||
// LookupAddr performs a reverse lookup for the given IP address,
|
||||
// returning a list of hostnames mapping to that address.
|
||||
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||
|
||||
// LookupHost looks up the given hostname using the local resolver.
|
||||
// It returns a slice of that host's addresses (IPv4 and IPv6).
|
||||
LookupHost(ctx context.Context, host string) ([]string, error)
|
||||
}
|
||||
|
||||
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver.
|
||||
type StandardDNSResolver struct {
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings.
|
||||
func NewStandardDNSResolver() DNSResolver {
|
||||
return &StandardDNSResolver{
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupMX implements DNSResolver.LookupMX using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
|
||||
return r.resolver.LookupMX(ctx, name)
|
||||
}
|
||||
|
||||
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||
return r.resolver.LookupTXT(ctx, name)
|
||||
}
|
||||
|
||||
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
|
||||
return r.resolver.LookupAddr(ctx, addr)
|
||||
}
|
||||
|
||||
// LookupHost implements DNSResolver.LookupHost using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
|
||||
return r.resolver.LookupHost(ctx, host)
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
|
||||
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
|
||||
visited := make(map[string]bool)
|
||||
return d.resolveSPFRecords(domain, visited, 0, true)
|
||||
}
|
||||
|
||||
// resolveSPFRecords recursively resolves SPF records including include: directives
|
||||
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
||||
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
|
||||
const maxDepth = 10 // Prevent infinite recursion
|
||||
|
||||
if depth > maxDepth {
|
||||
return &[]api.SPFRecord{
|
||||
{
|
||||
Domain: &domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("Maximum SPF include depth exceeded"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent circular references
|
||||
if visited[domain] {
|
||||
return &[]api.SPFRecord{}
|
||||
}
|
||||
visited[domain] = true
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||
defer cancel()
|
||||
|
||||
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
||||
if err != nil {
|
||||
return &[]api.SPFRecord{
|
||||
{
|
||||
Domain: &domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Find SPF record (starts with "v=spf1")
|
||||
var spfRecord string
|
||||
spfCount := 0
|
||||
for _, txt := range txtRecords {
|
||||
if strings.HasPrefix(txt, "v=spf1") {
|
||||
spfRecord = txt
|
||||
spfCount++
|
||||
}
|
||||
}
|
||||
|
||||
if spfCount == 0 {
|
||||
return &[]api.SPFRecord{
|
||||
{
|
||||
Domain: &domain,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("No SPF record found"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var results []api.SPFRecord
|
||||
|
||||
if spfCount > 1 {
|
||||
results = append(results, api.SPFRecord{
|
||||
Domain: &domain,
|
||||
Record: &spfRecord,
|
||||
Valid: false,
|
||||
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
|
||||
})
|
||||
return &results
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
||||
|
||||
// Extract the "all" mechanism qualifier
|
||||
var allQualifier *api.SPFRecordAllQualifier
|
||||
var errMsg *string
|
||||
|
||||
if validationErr != nil {
|
||||
errMsg = api.PtrTo(validationErr.Error())
|
||||
} else {
|
||||
// Extract qualifier from the "all" mechanism
|
||||
if strings.HasSuffix(spfRecord, " -all") {
|
||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
|
||||
} else if strings.HasSuffix(spfRecord, " ~all") {
|
||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
|
||||
} else if strings.HasSuffix(spfRecord, " +all") {
|
||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
||||
} else if strings.HasSuffix(spfRecord, " ?all") {
|
||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
|
||||
} else if strings.HasSuffix(spfRecord, " all") {
|
||||
// Implicit + qualifier (default)
|
||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, api.SPFRecord{
|
||||
Domain: &domain,
|
||||
Record: &spfRecord,
|
||||
Valid: validationErr == nil,
|
||||
AllQualifier: allQualifier,
|
||||
Error: errMsg,
|
||||
})
|
||||
|
||||
// Check for redirect= modifier first (it replaces the entire SPF policy)
|
||||
redirectDomain := d.extractSPFRedirect(spfRecord)
|
||||
if redirectDomain != "" {
|
||||
// redirect= replaces the current domain's policy entirely
|
||||
// Only follow if no other mechanisms matched (per RFC 7208)
|
||||
redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false)
|
||||
if redirectRecords != nil {
|
||||
results = append(results, *redirectRecords...)
|
||||
}
|
||||
return &results
|
||||
}
|
||||
|
||||
// Extract and resolve include: directives
|
||||
includes := d.extractSPFIncludes(spfRecord)
|
||||
for _, includeDomain := range includes {
|
||||
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false)
|
||||
if includedRecords != nil {
|
||||
results = append(results, *includedRecords...)
|
||||
}
|
||||
}
|
||||
|
||||
return &results
|
||||
}
|
||||
|
||||
// extractSPFIncludes extracts all include: domains from an SPF record
|
||||
func (d *DNSAnalyzer) extractSPFIncludes(record string) []string {
|
||||
var includes []string
|
||||
re := regexp.MustCompile(`include:([^\s]+)`)
|
||||
matches := re.FindAllStringSubmatch(record, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
includes = append(includes, match[1])
|
||||
}
|
||||
}
|
||||
return includes
|
||||
}
|
||||
|
||||
// extractSPFRedirect extracts the redirect= domain from an SPF record
|
||||
// The redirect= modifier replaces the current domain's SPF policy with that of the target domain
|
||||
func (d *DNSAnalyzer) extractSPFRedirect(record string) string {
|
||||
re := regexp.MustCompile(`redirect=([^\s]+)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidSPFMechanism checks if a token is a valid SPF mechanism or modifier
|
||||
func (d *DNSAnalyzer) isValidSPFMechanism(token string) error {
|
||||
// Remove qualifier prefix if present (+, -, ~, ?)
|
||||
mechanism := strings.TrimLeft(token, "+-~?")
|
||||
|
||||
// Check if it's a modifier (contains =)
|
||||
if strings.Contains(mechanism, "=") {
|
||||
// Allow known modifiers: redirect=, exp=, and RFC 6652 modifiers (ra=, rp=, rr=)
|
||||
if strings.HasPrefix(mechanism, "redirect=") ||
|
||||
strings.HasPrefix(mechanism, "exp=") ||
|
||||
strings.HasPrefix(mechanism, "ra=") ||
|
||||
strings.HasPrefix(mechanism, "rp=") ||
|
||||
strings.HasPrefix(mechanism, "rr=") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a common mistake (using = instead of :)
|
||||
parts := strings.SplitN(mechanism, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
mechanismName := parts[0]
|
||||
knownMechanisms := []string{"include", "a", "mx", "ptr", "exists"}
|
||||
for _, known := range knownMechanisms {
|
||||
if mechanismName == known {
|
||||
return fmt.Errorf("invalid syntax '%s': mechanism '%s' should use ':' not '='", token, mechanismName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown modifier '%s'", token)
|
||||
}
|
||||
|
||||
// Check standalone mechanisms (no domain/value required)
|
||||
if mechanism == "all" || mechanism == "a" || mechanism == "mx" || mechanism == "ptr" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check mechanisms with domain/value
|
||||
knownPrefixes := []string{
|
||||
"include:",
|
||||
"a:", "a/",
|
||||
"mx:", "mx/",
|
||||
"ptr:",
|
||||
"ip4:",
|
||||
"ip6:",
|
||||
"exists:",
|
||||
}
|
||||
|
||||
for _, prefix := range knownPrefixes {
|
||||
if strings.HasPrefix(mechanism, prefix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown mechanism '%s'", token)
|
||||
}
|
||||
|
||||
// validateSPF performs basic SPF record validation
|
||||
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
||||
func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error {
|
||||
// Must start with v=spf1
|
||||
if !strings.HasPrefix(record, "v=spf1") {
|
||||
return fmt.Errorf("SPF record must start with 'v=spf1'")
|
||||
}
|
||||
|
||||
// Parse and validate each token in the SPF record
|
||||
tokens := strings.Fields(record)
|
||||
hasRedirect := false
|
||||
|
||||
for i, token := range tokens {
|
||||
// Skip the version tag
|
||||
if i == 0 && token == "v=spf1" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a valid mechanism
|
||||
if err := d.isValidSPFMechanism(token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Track if we have a redirect modifier
|
||||
mechanism := strings.TrimLeft(token, "+-~?")
|
||||
if strings.HasPrefix(mechanism, "redirect=") {
|
||||
hasRedirect = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for redirect= modifier (which replaces the need for an 'all' mechanism)
|
||||
if hasRedirect {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check for 'all' mechanism on the main record, not on included records
|
||||
if isMainRecord {
|
||||
// Check for common syntax issues
|
||||
// Should have a final mechanism (all, +all, -all, ~all, ?all)
|
||||
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
|
||||
hasValidEnding := false
|
||||
for _, ending := range validEndings {
|
||||
if strings.HasSuffix(record, ending) {
|
||||
hasValidEnding = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasValidEnding {
|
||||
return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasSPFStrictFail checks if SPF record has strict -all mechanism
|
||||
func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
|
||||
return strings.HasSuffix(record, " -all")
|
||||
}
|
||||
|
||||
func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) {
|
||||
// SPF is essential for email authentication
|
||||
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
||||
// Find the main SPF record by skipping redirects
|
||||
// Loop through records to find the last redirect or the first non-redirect
|
||||
mainSPFIndex := 0
|
||||
for i := 0; i < len(*results.SpfRecords); i++ {
|
||||
spfRecord := (*results.SpfRecords)[i]
|
||||
if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") {
|
||||
// This is a redirect, check if there's a next record
|
||||
if i+1 < len(*results.SpfRecords) {
|
||||
mainSPFIndex = i + 1
|
||||
} else {
|
||||
// Redirect exists but no target record found
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Found a non-redirect record
|
||||
mainSPFIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mainSPF := (*results.SpfRecords)[mainSPFIndex]
|
||||
if mainSPF.Valid {
|
||||
// Full points for valid SPF
|
||||
score += 75
|
||||
|
||||
// Check if DMARC is configured with strict policy as all mechanism is less significant
|
||||
dmarcStrict := results.DmarcRecord != nil &&
|
||||
results.DmarcRecord.Valid && results.DmarcRecord.Policy != nil &&
|
||||
(*results.DmarcRecord.Policy == "quarantine" ||
|
||||
*results.DmarcRecord.Policy == "reject")
|
||||
|
||||
// Deduct points based on the all mechanism qualifier
|
||||
if mainSPF.AllQualifier != nil {
|
||||
switch *mainSPF.AllQualifier {
|
||||
case "-":
|
||||
// Strict fail - no deduction, this is the recommended policy
|
||||
score += 25
|
||||
case "~":
|
||||
// Softfail - if DMARC is quarantine or reject, treat it mostly like strict fail
|
||||
if dmarcStrict {
|
||||
score += 20
|
||||
}
|
||||
// Otherwise, moderate penalty (no points added or deducted)
|
||||
case "+", "?":
|
||||
// Pass/neutral - severe penalty
|
||||
if !dmarcStrict {
|
||||
score -= 25
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No 'all' mechanism qualifier extracted - severe penalty
|
||||
score -= 25
|
||||
}
|
||||
} else if mainSPF.Record != nil {
|
||||
// Partial credit if SPF record exists but has issues
|
||||
score += 25
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateSPF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectError bool
|
||||
errorMsg string // Expected error message (substring match)
|
||||
}{
|
||||
{
|
||||
name: "Valid SPF with -all",
|
||||
record: "v=spf1 include:_spf.example.com -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with ~all",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 ~all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with +all",
|
||||
record: "v=spf1 +all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with ?all",
|
||||
record: "v=spf1 mx ?all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with redirect",
|
||||
record: "v=spf1 redirect=_spf.example.com",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with redirect and mechanisms",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with multiple mechanisms",
|
||||
record: "v=spf1 a mx ip4:192.0.2.0/24 include:_spf.example.com -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with exp modifier",
|
||||
record: "v=spf1 mx exp=explain.example.com -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - no version",
|
||||
record: "include:_spf.example.com -all",
|
||||
expectError: true,
|
||||
errorMsg: "must start with 'v=spf1'",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - no all mechanism or redirect",
|
||||
record: "v=spf1 include:_spf.example.com",
|
||||
expectError: true,
|
||||
errorMsg: "should end with an 'all' mechanism",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - wrong version",
|
||||
record: "v=spf2 include:_spf.example.com -all",
|
||||
expectError: true,
|
||||
errorMsg: "must start with 'v=spf1'",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - include= instead of include:",
|
||||
record: "v=spf1 include=icloud.com ~all",
|
||||
expectError: true,
|
||||
errorMsg: "should use ':' not '='",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - a= instead of a:",
|
||||
record: "v=spf1 a=example.com -all",
|
||||
expectError: true,
|
||||
errorMsg: "should use ':' not '='",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - mx= instead of mx:",
|
||||
record: "v=spf1 mx=example.com -all",
|
||||
expectError: true,
|
||||
errorMsg: "should use ':' not '='",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - unknown mechanism",
|
||||
record: "v=spf1 foobar -all",
|
||||
expectError: true,
|
||||
errorMsg: "unknown mechanism",
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - unknown modifier",
|
||||
record: "v=spf1 -all unknown=value",
|
||||
expectError: true,
|
||||
errorMsg: "unknown modifier",
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with RFC 6652 ra modifier",
|
||||
record: "v=spf1 mx ra=postmaster -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with RFC 6652 rp modifier",
|
||||
record: "v=spf1 mx rp=100 -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with RFC 6652 rr modifier",
|
||||
record: "v=spf1 mx rr=all -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with all RFC 6652 modifiers",
|
||||
record: "v=spf1 mx ra=postmaster rp=50 rr=fail -all",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with RFC 6652 modifiers and redirect",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 ra=abuse redirect=_spf.example.com",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test as main record (isMainRecord = true) since these tests check overall SPF validity
|
||||
err := analyzer.validateSPF(tt.record, true)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("validateSPF(%q) expected error but got nil", tt.record)
|
||||
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Errorf("validateSPF(%q) error = %q, want error containing %q", tt.record, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateSPF(%q) unexpected error: %v", tt.record, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSPF_IncludedRecords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
isMainRecord bool
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Main record without 'all' - should error",
|
||||
record: "v=spf1 include:_spf.example.com",
|
||||
isMainRecord: true,
|
||||
expectError: true,
|
||||
errorMsg: "should end with an 'all' mechanism",
|
||||
},
|
||||
{
|
||||
name: "Included record without 'all' - should NOT error",
|
||||
record: "v=spf1 include:_spf.example.com",
|
||||
isMainRecord: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Included record with only mechanisms - should NOT error",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 mx",
|
||||
isMainRecord: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Main record with only mechanisms - should error",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 mx",
|
||||
isMainRecord: true,
|
||||
expectError: true,
|
||||
errorMsg: "should end with an 'all' mechanism",
|
||||
},
|
||||
{
|
||||
name: "Included record with 'all' - valid",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 -all",
|
||||
isMainRecord: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Main record with 'all' - valid",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 -all",
|
||||
isMainRecord: true,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := analyzer.validateSPF(tt.record, tt.isMainRecord)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord)
|
||||
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSPFRedirect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedRedirect string
|
||||
}{
|
||||
{
|
||||
name: "SPF with redirect",
|
||||
record: "v=spf1 redirect=_spf.example.com",
|
||||
expectedRedirect: "_spf.example.com",
|
||||
},
|
||||
{
|
||||
name: "SPF with redirect and other mechanisms",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com",
|
||||
expectedRedirect: "_spf.google.com",
|
||||
},
|
||||
{
|
||||
name: "SPF without redirect",
|
||||
record: "v=spf1 include:_spf.example.com -all",
|
||||
expectedRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "SPF with only all mechanism",
|
||||
record: "v=spf1 -all",
|
||||
expectedRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "Empty record",
|
||||
record: "",
|
||||
expectedRedirect: "",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractSPFRedirect(tt.record)
|
||||
if result != tt.expectedRedirect {
|
||||
t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -22,8 +22,12 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestNewDNSAnalyzer(t *testing.T) {
|
||||
|
|
@ -56,3 +60,761 @@ func TestNewDNSAnalyzer(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fromAddress string
|
||||
expectedDomain string
|
||||
}{
|
||||
{
|
||||
name: "Valid email",
|
||||
fromAddress: "user@example.com",
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Email with subdomain",
|
||||
fromAddress: "user@mail.example.com",
|
||||
expectedDomain: "mail.example.com",
|
||||
},
|
||||
{
|
||||
name: "Email with uppercase",
|
||||
fromAddress: "User@Example.COM",
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Invalid email (no @)",
|
||||
fromAddress: "invalid-email",
|
||||
expectedDomain: "",
|
||||
},
|
||||
{
|
||||
name: "Empty email",
|
||||
fromAddress: "",
|
||||
expectedDomain: "",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{
|
||||
Header: make(mail.Header),
|
||||
}
|
||||
if tt.fromAddress != "" {
|
||||
email.From = &mail.Address{
|
||||
Address: tt.fromAddress,
|
||||
}
|
||||
}
|
||||
|
||||
domain := analyzer.extractDomain(email)
|
||||
if domain != tt.expectedDomain {
|
||||
t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSPF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid SPF with -all",
|
||||
record: "v=spf1 include:_spf.example.com -all",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with ~all",
|
||||
record: "v=spf1 ip4:192.0.2.0/24 ~all",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with +all",
|
||||
record: "v=spf1 +all",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid SPF with ?all",
|
||||
record: "v=spf1 mx ?all",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - no version",
|
||||
record: "include:_spf.example.com -all",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - no all mechanism",
|
||||
record: "v=spf1 include:_spf.example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF - wrong version",
|
||||
record: "v=spf2 include:_spf.example.com -all",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateSPF(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDKIM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid DKIM with version",
|
||||
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid DKIM without version",
|
||||
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM - no public key",
|
||||
record: "v=DKIM1; k=rsa",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM - wrong version",
|
||||
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM - empty",
|
||||
record: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateDKIM(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedPolicy string
|
||||
}{
|
||||
{
|
||||
name: "Policy none",
|
||||
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
|
||||
expectedPolicy: "none",
|
||||
},
|
||||
{
|
||||
name: "Policy quarantine",
|
||||
record: "v=DMARC1; p=quarantine; pct=100",
|
||||
expectedPolicy: "quarantine",
|
||||
},
|
||||
{
|
||||
name: "Policy reject",
|
||||
record: "v=DMARC1; p=reject; sp=reject",
|
||||
expectedPolicy: "reject",
|
||||
},
|
||||
{
|
||||
name: "No policy",
|
||||
record: "v=DMARC1",
|
||||
expectedPolicy: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCPolicy(tt.record)
|
||||
if result != tt.expectedPolicy {
|
||||
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDMARC(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid DMARC",
|
||||
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid DMARC minimal",
|
||||
record: "v=DMARC1; p=none",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no version",
|
||||
record: "p=quarantine",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no policy",
|
||||
record: "v=DMARC1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - wrong version",
|
||||
record: "v=DMARC2; p=reject",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateDMARC(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMXCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *DNSResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Valid MX records",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
{Host: "mail2.example.com", Priority: 20, Valid: true},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "No MX records",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Valid: false, Error: "No MX records found"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "MX lookup failed",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Valid: false, Error: "DNS lookup failed"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateMXCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSPFCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spf *SPFRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Valid SPF",
|
||||
spf: &SPFRecord{
|
||||
Record: "v=spf1 include:_spf.example.com -all",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF",
|
||||
spf: &SPFRecord{
|
||||
Record: "v=spf1 invalid syntax",
|
||||
Valid: false,
|
||||
Error: "SPF record appears malformed",
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0.5,
|
||||
},
|
||||
{
|
||||
name: "No SPF record",
|
||||
spf: &SPFRecord{
|
||||
Valid: false,
|
||||
Error: "No SPF record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateSPFCheck(tt.spf)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDKIMCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dkim *DKIMRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Valid DKIM",
|
||||
dkim: &DKIMRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM",
|
||||
dkim: &DKIMRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Valid: false,
|
||||
Error: "No DKIM record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateDKIMCheck(tt.dkim)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
if !strings.Contains(check.Name, tt.dkim.Selector) {
|
||||
t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDMARCCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dmarc *DMARCRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Valid DMARC - reject",
|
||||
dmarc: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=reject",
|
||||
Policy: "reject",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Valid DMARC - quarantine",
|
||||
dmarc: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=quarantine",
|
||||
Policy: "quarantine",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Valid DMARC - none",
|
||||
dmarc: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=none",
|
||||
Policy: "none",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "No DMARC record",
|
||||
dmarc: &DMARCRecord{
|
||||
Valid: false,
|
||||
Error: "No DMARC record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateDMARCCheck(tt.dmarc)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
|
||||
// Check that advice mentions policy for valid DMARC
|
||||
if tt.dmarc.Valid && check.Advice != nil {
|
||||
if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
|
||||
t.Error("Advice should mention 'none' policy")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDNSChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *DNSResults
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "Complete results",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
},
|
||||
SPFRecord: &SPFRecord{
|
||||
Record: "v=spf1 include:_spf.example.com -all",
|
||||
Valid: true,
|
||||
},
|
||||
DKIMRecords: []DKIMRecord{
|
||||
{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
DMARCRecord: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=quarantine",
|
||||
Policy: "quarantine",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
minChecks: 4, // MX, SPF, DKIM, DMARC
|
||||
},
|
||||
{
|
||||
name: "Partial results",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
},
|
||||
},
|
||||
minChecks: 1, // Only MX
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateDNSChecks(tt.results)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the DNS category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDNS_NoDomain(t *testing.T) {
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
email := &EmailMessage{
|
||||
Header: make(mail.Header),
|
||||
// No From address
|
||||
}
|
||||
|
||||
results := analyzer.AnalyzeDNS(email, nil)
|
||||
|
||||
if results == nil {
|
||||
t.Fatal("Expected results, got nil")
|
||||
}
|
||||
|
||||
if len(results.Errors) == 0 {
|
||||
t.Error("Expected error when no domain can be extracted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBIMITag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
tag string
|
||||
expectedValue string
|
||||
}{
|
||||
{
|
||||
name: "Extract logo URL (l tag)",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
tag: "l",
|
||||
expectedValue: "https://example.com/logo.svg",
|
||||
},
|
||||
{
|
||||
name: "Extract VMC URL (a tag)",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||
tag: "a",
|
||||
expectedValue: "https://example.com/vmc.pem",
|
||||
},
|
||||
{
|
||||
name: "Tag not found",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
tag: "a",
|
||||
expectedValue: "",
|
||||
},
|
||||
{
|
||||
name: "Tag with spaces",
|
||||
record: "v=BIMI1; l= https://example.com/logo.svg ",
|
||||
tag: "l",
|
||||
expectedValue: "https://example.com/logo.svg",
|
||||
},
|
||||
{
|
||||
name: "Empty record",
|
||||
record: "",
|
||||
tag: "l",
|
||||
expectedValue: "",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractBIMITag(tt.record, tt.tag)
|
||||
if result != tt.expectedValue {
|
||||
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBIMI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid BIMI with logo URL",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid BIMI with logo and VMC",
|
||||
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - no version",
|
||||
record: "l=https://example.com/logo.svg",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - wrong version",
|
||||
record: "v=BIMI2; l=https://example.com/logo.svg",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - no logo URL",
|
||||
record: "v=BIMI1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI - empty",
|
||||
record: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.validateBIMI(tt.record)
|
||||
if result != tt.expected {
|
||||
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBIMICheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bimi *BIMIRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Valid BIMI with logo only",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
LogoURL: "https://example.com/logo.svg",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0.0, // BIMI doesn't contribute to score
|
||||
},
|
||||
{
|
||||
name: "Valid BIMI with VMC",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||
LogoURL: "https://example.com/logo.svg",
|
||||
VMCURL: "https://example.com/vmc.pem",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "No BIMI record (optional)",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Valid: false,
|
||||
Error: "No BIMI record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusInfo,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI record",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=BIMI1",
|
||||
Valid: false,
|
||||
Error: "BIMI record appears malformed",
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateBIMICheck(tt.bimi)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
if check.Name != "BIMI Record" {
|
||||
t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
|
||||
}
|
||||
|
||||
// Check details for valid BIMI with VMC
|
||||
if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
|
||||
if !strings.Contains(*check.Details, "VMC URL") {
|
||||
t.Error("Details should contain VMC URL for valid BIMI with VMC")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,696 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// HeaderAnalyzer analyzes email header quality and structure
|
||||
type HeaderAnalyzer struct{}
|
||||
|
||||
// NewHeaderAnalyzer creates a new header analyzer
|
||||
func NewHeaderAnalyzer() *HeaderAnalyzer {
|
||||
return &HeaderAnalyzer{}
|
||||
}
|
||||
|
||||
// CalculateHeaderScore evaluates email structural quality from header analysis
|
||||
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
|
||||
if analysis == nil || analysis.Headers == nil {
|
||||
return 0, ' '
|
||||
}
|
||||
|
||||
score := 0
|
||||
maxGrade := 6
|
||||
headers := *analysis.Headers
|
||||
|
||||
// RP and From alignment (25 points)
|
||||
if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
|
||||
// Bad domain alignment, cap grade to C
|
||||
maxGrade -= 2
|
||||
} else if *analysis.DomainAlignment.Aligned {
|
||||
score += 25
|
||||
} else if *analysis.DomainAlignment.RelaxedAligned {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Check required headers (RFC 5322) - 30 points
|
||||
requiredHeaders := []string{"from", "date", "message-id"}
|
||||
requiredCount := len(requiredHeaders)
|
||||
presentRequired := 0
|
||||
|
||||
for _, headerName := range requiredHeaders {
|
||||
if check, exists := headers[headerName]; exists && check.Present {
|
||||
presentRequired++
|
||||
}
|
||||
}
|
||||
|
||||
if presentRequired == requiredCount {
|
||||
score += 30
|
||||
} else {
|
||||
score += int(30 * (float32(presentRequired) / float32(requiredCount)))
|
||||
maxGrade = 1
|
||||
}
|
||||
|
||||
// Check recommended headers (15 points)
|
||||
recommendedHeaders := []string{"subject", "to"}
|
||||
|
||||
// Add reply-to when from is a no-reply address
|
||||
if h.isNoReplyAddress(headers["from"]) {
|
||||
recommendedHeaders = append(recommendedHeaders, "reply-to")
|
||||
}
|
||||
|
||||
recommendedCount := len(recommendedHeaders)
|
||||
presentRecommended := 0
|
||||
|
||||
for _, headerName := range recommendedHeaders {
|
||||
if check, exists := headers[headerName]; exists && check.Present {
|
||||
presentRecommended++
|
||||
}
|
||||
}
|
||||
score += presentRecommended * 15 / recommendedCount
|
||||
|
||||
if presentRecommended < recommendedCount {
|
||||
maxGrade -= 1
|
||||
}
|
||||
|
||||
// Check for proper MIME structure (20 points)
|
||||
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
|
||||
score += 20
|
||||
} else {
|
||||
maxGrade -= 1
|
||||
}
|
||||
|
||||
// Check MIME-Version header (-5 points if present but not "1.0")
|
||||
if check, exists := headers["mime-version"]; exists && check.Present {
|
||||
if check.Valid != nil && !*check.Valid {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
|
||||
// Check Message-ID format (10 points)
|
||||
if check, exists := headers["message-id"]; exists && check.Present {
|
||||
// If Valid is set and true, award points
|
||||
if check.Valid != nil && *check.Valid {
|
||||
score += 10
|
||||
} else {
|
||||
maxGrade -= 1
|
||||
}
|
||||
} else {
|
||||
maxGrade -= 1
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed 100
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
grade := 'A' + max(6-maxGrade, 0)
|
||||
|
||||
return score, rune(grade)
|
||||
}
|
||||
|
||||
// isValidMessageID checks if a Message-ID has proper format
|
||||
func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool {
|
||||
// Basic check: should be in format <...@...>
|
||||
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove angle brackets
|
||||
messageID = strings.TrimPrefix(messageID, "<")
|
||||
messageID = strings.TrimSuffix(messageID, ">")
|
||||
|
||||
// Should contain @ symbol
|
||||
if !strings.Contains(messageID, "@") {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(messageID, "@")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Both parts should be non-empty
|
||||
return len(parts[0]) > 0 && len(parts[1]) > 0
|
||||
}
|
||||
|
||||
// parseEmailDate attempts to parse an email date string using common email date formats
|
||||
// Returns the parsed time and an error if parsing fails
|
||||
func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
|
||||
// Remove timezone name in parentheses if present
|
||||
dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "")
|
||||
|
||||
// Try parsing with common email date formats
|
||||
formats := []string{
|
||||
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
|
||||
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||||
"2 Jan 2006 15:04:05 -0700",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if parsedTime, err := time.Parse(format, dateStr); err == nil {
|
||||
return parsedTime, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr)
|
||||
}
|
||||
|
||||
// isNoReplyAddress checks if a header check represents a no-reply email address
|
||||
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
|
||||
if !headerCheck.Present || headerCheck.Value == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
value := strings.ToLower(*headerCheck.Value)
|
||||
noReplyPatterns := []string{
|
||||
"no-reply",
|
||||
"noreply",
|
||||
"ne-pas-repondre",
|
||||
"nepasrepondre",
|
||||
}
|
||||
|
||||
for _, pattern := range noReplyPatterns {
|
||||
if strings.Contains(value, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// validateAddressHeader validates email address header using net/mail parser
|
||||
// and returns the normalized address string in "Name <email>" format
|
||||
func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) {
|
||||
// Try to parse as a single address first
|
||||
if addr, err := mail.ParseAddress(value); err == nil {
|
||||
return h.formatAddress(addr), nil
|
||||
}
|
||||
|
||||
// If single address parsing fails, try parsing as an address list
|
||||
// (for headers like To, Cc that can contain multiple addresses)
|
||||
if addrs, err := mail.ParseAddressList(value); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
// Join multiple addresses with ", "
|
||||
result := ""
|
||||
for i, addr := range addrs {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += h.formatAddress(addr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// formatAddress formats a mail.Address as "Name <email>" or just "email" if no name
|
||||
func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
|
||||
if addr.Name != "" {
|
||||
return fmt.Sprintf("%s <%s>", addr.Name, addr.Address)
|
||||
}
|
||||
return addr.Address
|
||||
}
|
||||
|
||||
// GenerateHeaderAnalysis creates structured header analysis from email
|
||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
|
||||
if email == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
analysis := &api.HeaderAnalysis{}
|
||||
|
||||
// Check for proper MIME structure
|
||||
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
|
||||
|
||||
// Initialize headers map
|
||||
headers := make(map[string]api.HeaderCheck)
|
||||
|
||||
// Check required headers
|
||||
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
||||
for _, headerName := range requiredHeaders {
|
||||
check := h.checkHeader(email, headerName, "required")
|
||||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
// Check recommended headers
|
||||
recommendedHeaders := []string{}
|
||||
if h.isNoReplyAddress(headers["from"]) {
|
||||
recommendedHeaders = append(recommendedHeaders, "reply-to")
|
||||
}
|
||||
for _, headerName := range recommendedHeaders {
|
||||
check := h.checkHeader(email, headerName, "recommended")
|
||||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
// Check MIME-Version header (recommended but absence is not penalized)
|
||||
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
|
||||
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
|
||||
|
||||
// Check optional headers
|
||||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
||||
for _, headerName := range optionalHeaders {
|
||||
check := h.checkHeader(email, headerName, "newsletter")
|
||||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
analysis.Headers = &headers
|
||||
|
||||
// Received chain
|
||||
receivedChain := h.parseReceivedChain(email)
|
||||
if len(receivedChain) > 0 {
|
||||
analysis.ReceivedChain = &receivedChain
|
||||
}
|
||||
|
||||
// Domain alignment
|
||||
domainAlignment := h.analyzeDomainAlignment(email, authResults)
|
||||
if domainAlignment != nil {
|
||||
analysis.DomainAlignment = domainAlignment
|
||||
}
|
||||
|
||||
// Header issues
|
||||
issues := h.findHeaderIssues(email)
|
||||
if len(issues) > 0 {
|
||||
analysis.Issues = &issues
|
||||
}
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// checkHeader checks if a header is present and valid
|
||||
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
|
||||
value := email.GetHeaderValue(headerName)
|
||||
present := email.HasHeader(headerName) && value != ""
|
||||
|
||||
importanceEnum := api.HeaderCheckImportance(importance)
|
||||
check := &api.HeaderCheck{
|
||||
Present: present,
|
||||
Importance: &importanceEnum,
|
||||
}
|
||||
|
||||
if present {
|
||||
check.Value = &value
|
||||
|
||||
// Validate specific headers
|
||||
valid := true
|
||||
var headerIssues []string
|
||||
|
||||
switch headerName {
|
||||
case "Message-ID":
|
||||
if !h.isValidMessageID(value) {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||||
}
|
||||
if len(email.Header["Message-Id"]) > 1 {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
|
||||
}
|
||||
case "Date":
|
||||
// Validate date format
|
||||
if _, err := h.parseEmailDate(value); err != nil {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
||||
}
|
||||
case "MIME-Version":
|
||||
if value != "1.0" {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
|
||||
}
|
||||
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
||||
// Parse address header using net/mail and get normalized address
|
||||
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err))
|
||||
} else {
|
||||
// Use the normalized address as the value
|
||||
check.Value = &normalizedAddr
|
||||
}
|
||||
}
|
||||
|
||||
check.Valid = &valid
|
||||
if len(headerIssues) > 0 {
|
||||
check.Issues = &headerIssues
|
||||
}
|
||||
} else {
|
||||
valid := false
|
||||
check.Valid = &valid
|
||||
if importance == "required" {
|
||||
issues := []string{"Required header is missing"}
|
||||
check.Issues = &issues
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
|
||||
alignment := &api.DomainAlignment{
|
||||
Aligned: api.PtrTo(true),
|
||||
RelaxedAligned: api.PtrTo(true),
|
||||
}
|
||||
|
||||
// Extract From domain
|
||||
fromAddr := email.GetHeaderValue("From")
|
||||
if fromAddr != "" {
|
||||
domain := h.extractDomain(fromAddr)
|
||||
if domain != "" {
|
||||
alignment.FromDomain = &domain
|
||||
// Extract organizational domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
alignment.FromOrgDomain = &orgDomain
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Return-Path domain
|
||||
returnPath := email.GetHeaderValue("Return-Path")
|
||||
if returnPath != "" {
|
||||
domain := h.extractDomain(returnPath)
|
||||
if domain != "" {
|
||||
alignment.ReturnPathDomain = &domain
|
||||
// Extract organizational domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
alignment.ReturnPathOrgDomain = &orgDomain
|
||||
}
|
||||
}
|
||||
|
||||
// Extract DKIM domains from authentication results
|
||||
var dkimDomains []api.DKIMDomainInfo
|
||||
if authResults != nil && authResults.Dkim != nil {
|
||||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||
domain := *dkim.Domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
|
||||
Domain: domain,
|
||||
OrgDomain: orgDomain,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dkimDomains) > 0 {
|
||||
alignment.DkimDomains = &dkimDomains
|
||||
}
|
||||
|
||||
// Check alignment (strict and relaxed)
|
||||
issues := []string{}
|
||||
|
||||
// hasReturnPath and hasDKIM track whether we have these fields to check
|
||||
hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil
|
||||
hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0
|
||||
|
||||
// If neither Return-Path nor DKIM is present, keep default alignment (true)
|
||||
// Otherwise, at least one must be aligned for overall alignment to be true
|
||||
strictAligned := !hasReturnPath && !hasDKIM
|
||||
relaxedAligned := !hasReturnPath && !hasDKIM
|
||||
|
||||
// Check Return-Path alignment
|
||||
rpStrictAligned := false
|
||||
rpRelaxedAligned := false
|
||||
if hasReturnPath {
|
||||
fromDomain := *alignment.FromDomain
|
||||
rpDomain := *alignment.ReturnPathDomain
|
||||
|
||||
// Strict alignment: exact match (case-insensitive)
|
||||
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
|
||||
|
||||
// Relaxed alignment: organizational domain match
|
||||
var fromOrgDomain, rpOrgDomain string
|
||||
if alignment.FromOrgDomain != nil {
|
||||
fromOrgDomain = *alignment.FromOrgDomain
|
||||
}
|
||||
if alignment.ReturnPathOrgDomain != nil {
|
||||
rpOrgDomain = *alignment.ReturnPathOrgDomain
|
||||
}
|
||||
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
|
||||
|
||||
if !rpStrictAligned {
|
||||
if rpRelaxedAligned {
|
||||
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain))
|
||||
} else {
|
||||
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain))
|
||||
}
|
||||
}
|
||||
|
||||
strictAligned = rpStrictAligned
|
||||
relaxedAligned = rpRelaxedAligned
|
||||
}
|
||||
|
||||
// Check DKIM alignment
|
||||
dkimStrictAligned := false
|
||||
dkimRelaxedAligned := false
|
||||
if hasDKIM {
|
||||
fromDomain := *alignment.FromDomain
|
||||
var fromOrgDomain string
|
||||
if alignment.FromOrgDomain != nil {
|
||||
fromOrgDomain = *alignment.FromOrgDomain
|
||||
}
|
||||
|
||||
for _, dkimDomain := range dkimDomains {
|
||||
// Check strict alignment for this DKIM signature
|
||||
if strings.EqualFold(fromDomain, dkimDomain.Domain) {
|
||||
dkimStrictAligned = true
|
||||
}
|
||||
|
||||
// Check relaxed alignment for this DKIM signature
|
||||
if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) {
|
||||
dkimRelaxedAligned = true
|
||||
}
|
||||
}
|
||||
|
||||
if !dkimStrictAligned && !dkimRelaxedAligned {
|
||||
// List all DKIM domains that failed alignment
|
||||
dkimDomainsList := []string{}
|
||||
for _, dkimDomain := range dkimDomains {
|
||||
dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain)
|
||||
}
|
||||
issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain))
|
||||
} else if !dkimStrictAligned && dkimRelaxedAligned {
|
||||
// DKIM has relaxed alignment but not strict
|
||||
issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain))
|
||||
}
|
||||
|
||||
// Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned
|
||||
// For DMARC compliance, at least one of SPF or DKIM must be aligned
|
||||
if dkimStrictAligned {
|
||||
strictAligned = true
|
||||
}
|
||||
if dkimRelaxedAligned {
|
||||
relaxedAligned = true
|
||||
}
|
||||
}
|
||||
|
||||
*alignment.Aligned = strictAligned
|
||||
*alignment.RelaxedAligned = relaxedAligned
|
||||
|
||||
if len(issues) > 0 {
|
||||
alignment.Issues = &issues
|
||||
}
|
||||
|
||||
return alignment
|
||||
}
|
||||
|
||||
// extractDomain extracts domain from email address
|
||||
func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
|
||||
// Remove angle brackets if present
|
||||
emailAddr = strings.Trim(emailAddr, "<> ")
|
||||
|
||||
// Find @ symbol
|
||||
atIndex := strings.LastIndex(emailAddr, "@")
|
||||
if atIndex == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
domain := emailAddr[atIndex+1:]
|
||||
// Remove any trailing >
|
||||
domain = strings.TrimRight(domain, ">")
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
||||
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
|
||||
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
|
||||
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
|
||||
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
||||
// This correctly handles cases like .co.uk, .com.au, etc.
|
||||
etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain)
|
||||
if err != nil {
|
||||
// Fallback to simple two-label extraction if PSL lookup fails
|
||||
labels := strings.Split(domain, ".")
|
||||
if len(labels) <= 2 {
|
||||
return domain
|
||||
}
|
||||
return strings.Join(labels[len(labels)-2:], ".")
|
||||
}
|
||||
|
||||
return etldPlusOne
|
||||
}
|
||||
|
||||
// findHeaderIssues identifies issues with headers
|
||||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
|
||||
var issues []api.HeaderIssue
|
||||
|
||||
// Check for missing required headers
|
||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||
for _, header := range requiredHeaders {
|
||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||
issues = append(issues, api.HeaderIssue{
|
||||
Header: header,
|
||||
Severity: api.HeaderIssueSeverityCritical,
|
||||
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
||||
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check Message-ID format
|
||||
messageID := email.GetHeaderValue("Message-ID")
|
||||
if messageID != "" && !h.isValidMessageID(messageID) {
|
||||
issues = append(issues, api.HeaderIssue{
|
||||
Header: "Message-ID",
|
||||
Severity: api.HeaderIssueSeverityMedium,
|
||||
Message: "Message-ID format is invalid",
|
||||
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
// parseReceivedChain extracts the chain of Received headers from an email
|
||||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
|
||||
if email == nil || email.Header == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
receivedHeaders := email.Header["Received"]
|
||||
if len(receivedHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var chain []api.ReceivedHop
|
||||
|
||||
for _, receivedValue := range receivedHeaders {
|
||||
hop := h.parseReceivedHeader(receivedValue)
|
||||
if hop != nil {
|
||||
chain = append(chain, *hop)
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
// parseReceivedHeader parses a single Received header value
|
||||
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
|
||||
hop := &api.ReceivedHop{}
|
||||
|
||||
// Normalize whitespace - Received headers can span multiple lines
|
||||
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
||||
|
||||
// Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)")
|
||||
// vs standard "from-first" header (e.g., "from hostname ... by hostname")
|
||||
isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized))
|
||||
|
||||
// Extract "from" field - only if not in "by-first" format
|
||||
// Avoid matching "from" inside parentheses after "by"
|
||||
if !isByFirst {
|
||||
fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`)
|
||||
if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
from := matches[1]
|
||||
hop.From = &from
|
||||
}
|
||||
}
|
||||
|
||||
// Extract "by" field
|
||||
byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`)
|
||||
if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
by := matches[1]
|
||||
hop.By = &by
|
||||
}
|
||||
|
||||
// Extract "with" field (protocol) - must come after "by" and before "id" or "for"
|
||||
// This ensures we get the mail transfer protocol, not other "with" occurrences
|
||||
// Avoid matching "with" inside parentheses (like in TLS details)
|
||||
withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`)
|
||||
if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
with := matches[1]
|
||||
hop.With = &with
|
||||
}
|
||||
|
||||
// Extract "id" field - should come after "with" or "by", not inside parentheses
|
||||
// Match pattern: "id <value>" where value doesn't contain parentheses or semicolons
|
||||
idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`)
|
||||
if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
id := matches[1]
|
||||
hop.Id = &id
|
||||
}
|
||||
|
||||
// Extract IP address from parentheses after "from"
|
||||
// Pattern: from hostname (anything [IPv4/IPv6])
|
||||
ipRegex := regexp.MustCompile(`\[([^\]]+)\]`)
|
||||
if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
ipStr := matches[1]
|
||||
|
||||
// Handle IPv6: prefix (some MTAs include this)
|
||||
ipStr = strings.TrimPrefix(ipStr, "IPv6:")
|
||||
|
||||
// Check if it's a valid IP (IPv4 or IPv6)
|
||||
if net.ParseIP(ipStr) != nil {
|
||||
hop.Ip = &ipStr
|
||||
|
||||
// Perform reverse DNS lookup
|
||||
if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 {
|
||||
// Remove trailing dot from PTR record
|
||||
reverse := strings.TrimSuffix(reverseNames[0], ".")
|
||||
hop.Reverse = &reverse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract timestamp - usually at the end after semicolon
|
||||
// Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)"
|
||||
timestampRegex := regexp.MustCompile(`;\s*(.+)$`)
|
||||
if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
timestampStr := strings.TrimSpace(matches[1])
|
||||
|
||||
// Use the dedicated date parsing function
|
||||
if parsedTime, err := h.parseEmailDate(timestampStr); err == nil {
|
||||
hop.Timestamp = &parsedTime
|
||||
}
|
||||
}
|
||||
|
||||
return hop
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,16 +28,9 @@ import (
|
|||
"mime/multipart"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var hostname = ""
|
||||
|
||||
func init() {
|
||||
hostname, _ = os.Hostname()
|
||||
}
|
||||
|
||||
// EmailMessage represents a parsed email message
|
||||
type EmailMessage struct {
|
||||
Header mail.Header
|
||||
|
|
@ -218,27 +211,8 @@ func buildRawHeaders(header mail.Header) string {
|
|||
}
|
||||
|
||||
// GetAuthenticationResults extracts Authentication-Results headers
|
||||
// If hostname is provided, only returns headers that begin with that hostname
|
||||
func (e *EmailMessage) GetAuthenticationResults() []string {
|
||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||
|
||||
// If no hostname specified, return all results
|
||||
if hostname == "" {
|
||||
return allResults
|
||||
}
|
||||
|
||||
// Filter results that begin with the specified hostname
|
||||
var filtered []string
|
||||
prefix := hostname + ";"
|
||||
for _, result := range allResults {
|
||||
// Trim whitespace and check if it starts with hostname;
|
||||
trimmed := strings.TrimSpace(result)
|
||||
if strings.HasPrefix(trimmed, prefix) {
|
||||
filtered = append(filtered, result)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||
}
|
||||
|
||||
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
|
||||
|
|
@ -256,33 +230,6 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
|
|||
}
|
||||
|
||||
for _, headerName := range saHeaders {
|
||||
if values, ok := e.Header[headerName]; ok && len(values) > 0 {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
headers[headerName] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// GetRspamdHeaders extracts rspamd-related headers
|
||||
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
|
||||
headers := make(map[string]string)
|
||||
|
||||
rspamdHeaders := []string{
|
||||
"X-Spamd-Result",
|
||||
"X-Rspamd-Score",
|
||||
"X-Rspamd-Action",
|
||||
"X-Rspamd-Server",
|
||||
}
|
||||
|
||||
for _, headerName := range rspamdHeaders {
|
||||
if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
|
|
@ -328,20 +275,3 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
|
|||
func (e *EmailMessage) HasHeader(key string) bool {
|
||||
return e.Header.Get(key) != ""
|
||||
}
|
||||
|
||||
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
|
||||
// The header format is: <url1>, <url2>, ...
|
||||
func (e *EmailMessage) GetListUnsubscribeURLs() []string {
|
||||
value := e.Header.Get("List-Unsubscribe")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var urls []string
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") {
|
||||
urls = append(urls, part[1:len(part)-1])
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8
|
|||
}
|
||||
|
||||
func TestGetAuthenticationResults(t *testing.T) {
|
||||
// Force hostname
|
||||
hostname = "example.com"
|
||||
|
||||
rawEmail := `From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Email
|
||||
|
|
|
|||
|
|
@ -27,21 +27,16 @@ import (
|
|||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
||||
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
|
||||
type DNSListChecker struct {
|
||||
Timeout time.Duration
|
||||
Lists []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
|
||||
resolver *net.Resolver
|
||||
informationalSet map[string]bool // Lists whose hits don't count toward the score
|
||||
// RBLChecker checks IP addresses against DNS-based blacklists
|
||||
type RBLChecker struct {
|
||||
Timeout time.Duration
|
||||
RBLs []string
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
// DefaultRBLs is a list of commonly used RBL providers
|
||||
|
|
@ -52,83 +47,46 @@ var DefaultRBLs = []string{
|
|||
"b.barracudacentral.org", // Barracuda
|
||||
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
||||
"psbl.surriel.com", // PSBL
|
||||
"dnsbl.dronebl.org", // DroneBL
|
||||
"bl.mailspike.net", // Mailspike BL
|
||||
"z.mailspike.net", // Mailspike Z
|
||||
"bl.rbl-dns.com", // RBL-DNS
|
||||
"bl.nszones.com", // NSZones
|
||||
}
|
||||
|
||||
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
|
||||
// These are typically broader lists where being listed is less definitive.
|
||||
var DefaultInformationalRBLs = []string{
|
||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
|
||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
|
||||
}
|
||||
|
||||
// DefaultDNSWLs is a list of commonly used DNSWL providers
|
||||
var DefaultDNSWLs = []string{
|
||||
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
|
||||
"swl.spamhaus.org", // Spamhaus Safe Whitelist
|
||||
}
|
||||
|
||||
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
timeout = 5 * time.Second // Default timeout
|
||||
}
|
||||
if len(rbls) == 0 {
|
||||
rbls = DefaultRBLs
|
||||
}
|
||||
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
|
||||
for _, rbl := range DefaultInformationalRBLs {
|
||||
informationalSet[rbl] = true
|
||||
}
|
||||
return &DNSListChecker{
|
||||
Timeout: timeout,
|
||||
Lists: rbls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
filterErrorCodes: true,
|
||||
resolver: &net.Resolver{PreferGo: true},
|
||||
informationalSet: informationalSet,
|
||||
return &RBLChecker{
|
||||
Timeout: timeout,
|
||||
RBLs: rbls,
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
|
||||
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if len(dnswls) == 0 {
|
||||
dnswls = DefaultDNSWLs
|
||||
}
|
||||
return &DNSListChecker{
|
||||
Timeout: timeout,
|
||||
Lists: dnswls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
filterErrorCodes: false,
|
||||
resolver: &net.Resolver{PreferGo: true},
|
||||
informationalSet: make(map[string]bool),
|
||||
}
|
||||
// RBLResults represents the results of RBL checks
|
||||
type RBLResults struct {
|
||||
Checks []RBLCheck
|
||||
IPsChecked []string
|
||||
ListedCount int
|
||||
}
|
||||
|
||||
// DNSListResults represents the results of DNS list checks
|
||||
type DNSListResults struct {
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int // Total listings including informational entries
|
||||
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||
// RBLCheck represents a single RBL check result
|
||||
type RBLCheck struct {
|
||||
IP string
|
||||
RBL string
|
||||
Listed bool
|
||||
Response string
|
||||
Error string
|
||||
}
|
||||
|
||||
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||
results := &DNSListResults{
|
||||
Checks: make(map[string][]api.BlacklistCheck),
|
||||
}
|
||||
// CheckEmail checks all IPs found in the email headers against RBLs
|
||||
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||
results := &RBLResults{}
|
||||
|
||||
// Extract IPs from Received headers
|
||||
ips := r.extractIPs(email)
|
||||
if len(ips) == 0 {
|
||||
return results
|
||||
|
|
@ -136,68 +94,42 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
|||
|
||||
results.IPsChecked = ips
|
||||
|
||||
// Check each IP against all RBLs
|
||||
for _, ip := range ips {
|
||||
for _, list := range r.Lists {
|
||||
check := r.checkIP(ip, list)
|
||||
results.Checks[ip] = append(results.Checks[ip], check)
|
||||
for _, rbl := range r.RBLs {
|
||||
check := r.checkIP(ip, rbl)
|
||||
results.Checks = append(results.Checks, check)
|
||||
if check.Listed {
|
||||
results.ListedCount++
|
||||
if !r.informationalSet[list] {
|
||||
results.RelevantListedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !r.CheckAllIPs {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// CheckIP checks a single IP address against all configured lists in parallel
|
||||
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||
if !r.isPublicIP(ip) {
|
||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||
}
|
||||
|
||||
checks := make([]api.BlacklistCheck, len(r.Lists))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, list := range r.Lists {
|
||||
wg.Add(1)
|
||||
go func(i int, list string) {
|
||||
defer wg.Done()
|
||||
checks[i] = r.checkIP(ip, list)
|
||||
}(i, list)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
listedCount := 0
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
listedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return checks, listedCount, nil
|
||||
}
|
||||
|
||||
// extractIPs extracts IP addresses from Received headers
|
||||
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
||||
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||
var ips []string
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
// Get all Received headers
|
||||
receivedHeaders := email.Header["Received"]
|
||||
|
||||
// Regex patterns for IP addresses
|
||||
// Match IPv4: xxx.xxx.xxx.xxx
|
||||
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
|
||||
|
||||
// Look for IPs in Received headers
|
||||
for _, received := range receivedHeaders {
|
||||
// Find all IPv4 addresses
|
||||
matches := ipv4Pattern.FindAllString(received, -1)
|
||||
for _, match := range matches {
|
||||
// Skip private/reserved IPs
|
||||
if !r.isPublicIP(match) {
|
||||
continue
|
||||
}
|
||||
// Avoid duplicates
|
||||
if !seenIPs[match] {
|
||||
ips = append(ips, match)
|
||||
seenIPs[match] = true
|
||||
|
|
@ -205,10 +137,13 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
|||
}
|
||||
}
|
||||
|
||||
// If no IPs found in Received headers, try X-Originating-IP
|
||||
if len(ips) == 0 {
|
||||
originatingIP := email.Header.Get("X-Originating-IP")
|
||||
if originatingIP != "" {
|
||||
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
|
||||
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||
// Remove any whitespace
|
||||
cleanIP = strings.TrimSpace(cleanIP)
|
||||
matches := ipv4Pattern.FindString(cleanIP)
|
||||
if matches != "" && r.isPublicIP(matches) {
|
||||
|
|
@ -221,16 +156,19 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
|||
}
|
||||
|
||||
// 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)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a private network
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Additional checks for reserved ranges
|
||||
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
|
|
@ -238,109 +176,233 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// checkIP checks a single IP against a single DNS list
|
||||
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
||||
check := api.BlacklistCheck{
|
||||
Rbl: list,
|
||||
// checkIP checks a single IP against a single RBL
|
||||
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
|
||||
check := RBLCheck{
|
||||
IP: ip,
|
||||
RBL: rbl,
|
||||
}
|
||||
|
||||
// Reverse the IP for DNSBL query
|
||||
reversedIP := r.reverseIP(ip)
|
||||
if reversedIP == "" {
|
||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||
check.Error = "Failed to reverse IP address"
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||
if err != nil {
|
||||
// Most likely not listed (NXDOMAIN)
|
||||
if dnsErr, ok := err.(*net.DNSError); ok {
|
||||
if dnsErr.IsNotFound {
|
||||
check.Listed = false
|
||||
return check
|
||||
}
|
||||
}
|
||||
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||
// Other DNS errors
|
||||
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
|
||||
return check
|
||||
}
|
||||
|
||||
// If we got a response, the IP is listed
|
||||
if len(addrs) > 0 {
|
||||
check.Response = api.PtrTo(addrs[0])
|
||||
|
||||
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
||||
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
||||
check.Listed = false
|
||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
||||
} else {
|
||||
check.Listed = true
|
||||
}
|
||||
check.Listed = true
|
||||
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
|
||||
}
|
||||
|
||||
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
|
||||
func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||
func (r *RBLChecker) reverseIP(ipStr string) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Convert to IPv4
|
||||
ipv4 := ip.To4()
|
||||
if ipv4 == nil {
|
||||
return "" // IPv6 not supported yet
|
||||
}
|
||||
|
||||
// Reverse the octets
|
||||
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
||||
}
|
||||
|
||||
// CalculateScore calculates the list contribution to deliverability.
|
||||
// Informational lists are not counted in the score.
|
||||
func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) {
|
||||
// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points)
|
||||
// Scoring:
|
||||
// - Not listed on any RBL: 2 points (excellent)
|
||||
// - Listed on 1 RBL: 1 point (warning)
|
||||
// - Listed on 2-3 RBLs: 0.5 points (poor)
|
||||
// - Listed on 4+ RBLs: 0 points (critical)
|
||||
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 {
|
||||
if results == nil || len(results.IPsChecked) == 0 {
|
||||
return 100, ""
|
||||
// No IPs to check, give benefit of doubt
|
||||
return 2.0
|
||||
}
|
||||
|
||||
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
||||
if scoringListCount <= 0 {
|
||||
return 100, "A+"
|
||||
listedCount := results.ListedCount
|
||||
|
||||
if listedCount == 0 {
|
||||
return 2.0
|
||||
} else if listedCount == 1 {
|
||||
return 1.0
|
||||
} else if listedCount <= 3 {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
percentage := 100 - results.RelevantListedCount*100/scoringListCount
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
|
||||
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
||||
// GenerateRBLChecks generates check results for RBL analysis
|
||||
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if results == nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
// If no IPs were checked, add a warning
|
||||
if len(results.IPsChecked) == 0 {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: "RBL Check",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 1.0,
|
||||
Message: "No public IP addresses found to check",
|
||||
Severity: api.PtrTo(api.CheckSeverityLow),
|
||||
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
|
||||
})
|
||||
return checks
|
||||
}
|
||||
|
||||
// Create a summary check
|
||||
summaryCheck := r.generateSummaryCheck(results)
|
||||
checks = append(checks, summaryCheck)
|
||||
|
||||
// Create individual checks for each listing
|
||||
for _, check := range results.Checks {
|
||||
if check.Listed {
|
||||
detailCheck := r.generateListingCheck(&check)
|
||||
checks = append(checks, detailCheck)
|
||||
}
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateSummaryCheck creates an overall RBL summary check
|
||||
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: "RBL Summary",
|
||||
}
|
||||
|
||||
score := r.GetBlacklistScore(results)
|
||||
check.Score = score
|
||||
|
||||
totalChecks := len(results.Checks)
|
||||
listedCount := results.ListedCount
|
||||
|
||||
if listedCount == 0 {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
||||
} else if listedCount == 1 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
||||
} else if listedCount <= 3 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
||||
} else {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
|
||||
}
|
||||
|
||||
// Add details about IPs checked
|
||||
if len(results.IPsChecked) > 0 {
|
||||
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateListingCheck creates a check for a specific RBL listing
|
||||
func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
|
||||
Status: api.CheckStatusFail,
|
||||
Score: 0.0,
|
||||
}
|
||||
|
||||
check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
|
||||
|
||||
// Determine severity based on which RBL
|
||||
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
||||
check.Advice = &advice
|
||||
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
||||
check.Advice = &advice
|
||||
} else {
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
||||
check.Advice = &advice
|
||||
}
|
||||
|
||||
// Add response code details
|
||||
if rblCheck.Response != "" {
|
||||
details := fmt.Sprintf("Response: %s", rblCheck.Response)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
||||
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||
seenIPs := make(map[string]bool)
|
||||
var listedIPs []string
|
||||
|
||||
for ip, checks := range results.Checks {
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
listedIPs = append(listedIPs, ip)
|
||||
break
|
||||
}
|
||||
for _, check := range results.Checks {
|
||||
if check.Listed && !seenIPs[check.IP] {
|
||||
listedIPs = append(listedIPs, check.IP)
|
||||
seenIPs[check.IP] = true
|
||||
}
|
||||
}
|
||||
|
||||
return listedIPs
|
||||
}
|
||||
|
||||
// GetListsForIP returns all lists that match a specific IP
|
||||
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
|
||||
var lists []string
|
||||
// GetRBLsForIP returns all RBLs that list a specific IP
|
||||
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
||||
var rbls []string
|
||||
|
||||
if checks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
lists = append(lists, check.Rbl)
|
||||
}
|
||||
for _, check := range results.Checks {
|
||||
if check.IP == ip && check.Listed {
|
||||
rbls = append(rbls, check.RBL)
|
||||
}
|
||||
}
|
||||
|
||||
return lists
|
||||
return rbls
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ package analyzer
|
|||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -55,12 +56,12 @@ func TestNewRBLChecker(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checker := NewRBLChecker(tt.timeout, tt.rbls, false)
|
||||
checker := NewRBLChecker(tt.timeout, tt.rbls)
|
||||
if checker.Timeout != tt.expectedTimeout {
|
||||
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||
}
|
||||
if len(checker.Lists) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
|
||||
if len(checker.RBLs) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
||||
}
|
||||
if checker.resolver == nil {
|
||||
t.Error("Resolver should not be nil")
|
||||
|
|
@ -97,7 +98,7 @@ func TestReverseIP(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -157,7 +158,7 @@ func TestIsPublicIP(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -237,7 +238,7 @@ func TestExtractIPs(t *testing.T) {
|
|||
},*/
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -265,68 +266,68 @@ func TestExtractIPs(t *testing.T) {
|
|||
func TestGetBlacklistScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *DNSListResults
|
||||
expectedScore int
|
||||
results *RBLResults
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
expectedScore: 100,
|
||||
expectedScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "No IPs checked",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{},
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "Not listed on any RBL",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
},
|
||||
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
},
|
||||
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
|
||||
expectedScore: 0.5,
|
||||
},
|
||||
{
|
||||
name: "Listed on 3 RBLs",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 3,
|
||||
},
|
||||
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
|
||||
expectedScore: 0.5,
|
||||
},
|
||||
{
|
||||
name: "Listed on 4+ RBLs",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
},
|
||||
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := checker.CalculateScore(tt.results)
|
||||
score := checker.GetBlacklistScore(tt.results)
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
|
|
@ -334,24 +335,215 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetUniqueListedIPs(t *testing.T) {
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: true},
|
||||
func TestGenerateSummaryCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *RBLResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "Not listed",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
Checks: make([]RBLCheck, 6), // 6 default RBLs
|
||||
},
|
||||
"198.51.100.2": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: false},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
Checks: make([]RBLCheck, 6),
|
||||
},
|
||||
"198.51.100.3": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: false},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
Checks: make([]RBLCheck, 6),
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0.5,
|
||||
},
|
||||
{
|
||||
name: "Listed on 4+ RBLs",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
Checks: make([]RBLCheck, 6),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := checker.generateSummaryCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Blacklist {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateListingCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rblCheck *RBLCheck
|
||||
expectedStatus api.CheckStatus
|
||||
expectedSeverity api.CheckSeverity
|
||||
}{
|
||||
{
|
||||
name: "Spamhaus listing",
|
||||
rblCheck: &RBLCheck{
|
||||
IP: "198.51.100.1",
|
||||
RBL: "zen.spamhaus.org",
|
||||
Listed: true,
|
||||
Response: "127.0.0.2",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedSeverity: api.CheckSeverityCritical,
|
||||
},
|
||||
{
|
||||
name: "SpamCop listing",
|
||||
rblCheck: &RBLCheck{
|
||||
IP: "198.51.100.1",
|
||||
RBL: "bl.spamcop.net",
|
||||
Listed: true,
|
||||
Response: "127.0.0.2",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedSeverity: api.CheckSeverityHigh,
|
||||
},
|
||||
{
|
||||
name: "Other RBL listing",
|
||||
rblCheck: &RBLCheck{
|
||||
IP: "198.51.100.1",
|
||||
RBL: "dnsbl.sorbs.net",
|
||||
Listed: true,
|
||||
Response: "127.0.0.2",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedSeverity: api.CheckSeverityHigh,
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := checker.generateListingCheck(tt.rblCheck)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
|
||||
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
|
||||
}
|
||||
if check.Category != api.Blacklist {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
|
||||
}
|
||||
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
|
||||
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRBLChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *RBLResults
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "No IPs checked",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{},
|
||||
},
|
||||
minChecks: 1, // Warning check
|
||||
},
|
||||
{
|
||||
name: "Not listed on any RBL",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
Checks: []RBLCheck{
|
||||
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
|
||||
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
|
||||
},
|
||||
},
|
||||
minChecks: 1, // Summary check only
|
||||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
Checks: []RBLCheck{
|
||||
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
|
||||
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
|
||||
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
|
||||
},
|
||||
},
|
||||
minChecks: 3, // Summary + 2 listing checks
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := checker.GenerateRBLChecks(tt.results)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the Blacklist category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Blacklist {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUniqueListedIPs(t *testing.T) {
|
||||
results := &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
|
||||
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
|
||||
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
|
||||
{IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
|
||||
{IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
listedIPs := checker.GetUniqueListedIPs(results)
|
||||
|
||||
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
|
||||
|
|
@ -363,20 +555,16 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetRBLsForIP(t *testing.T) {
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: true},
|
||||
{Rbl: "dnsbl.sorbs.net", Listed: false},
|
||||
},
|
||||
"198.51.100.2": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
},
|
||||
results := &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
|
||||
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
|
||||
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
|
||||
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -402,7 +590,7 @@ func TestGetRBLsForIP(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
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) {
|
||||
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import (
|
|||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
|
@ -33,12 +32,10 @@ import (
|
|||
type ReportGenerator struct {
|
||||
authAnalyzer *AuthenticationAnalyzer
|
||||
spamAnalyzer *SpamAssassinAnalyzer
|
||||
rspamdAnalyzer *RspamdAnalyzer
|
||||
dnsAnalyzer *DNSAnalyzer
|
||||
rblChecker *DNSListChecker
|
||||
dnswlChecker *DNSListChecker
|
||||
rblChecker *RBLChecker
|
||||
contentAnalyzer *ContentAnalyzer
|
||||
headerAnalyzer *HeaderAnalyzer
|
||||
scorer *DeliverabilityScorer
|
||||
}
|
||||
|
||||
// NewReportGenerator creates a new report generator
|
||||
|
|
@ -46,18 +43,14 @@ func NewReportGenerator(
|
|||
dnsTimeout time.Duration,
|
||||
httpTimeout time.Duration,
|
||||
rbls []string,
|
||||
dnswls []string,
|
||||
checkAllIPs bool,
|
||||
) *ReportGenerator {
|
||||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls),
|
||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||
headerAnalyzer: NewHeaderAnalyzer(),
|
||||
scorer: NewDeliverabilityScorer(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,13 +58,11 @@ func NewReportGenerator(
|
|||
type AnalysisResults struct {
|
||||
Email *EmailMessage
|
||||
Authentication *api.AuthenticationResults
|
||||
SpamAssassin *SpamAssassinResult
|
||||
DNS *DNSResults
|
||||
RBL *RBLResults
|
||||
Content *ContentResults
|
||||
DNS *api.DNSResults
|
||||
Headers *api.HeaderAnalysis
|
||||
RBL *DNSListResults
|
||||
DNSWL *DNSListResults
|
||||
SpamAssassin *api.SpamAssassinResult
|
||||
Rspamd *api.RspamdResult
|
||||
Score *ScoringResult
|
||||
}
|
||||
|
||||
// AnalyzeEmail performs complete email analysis
|
||||
|
|
@ -82,14 +73,20 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
|
||||
// Run all analyzers
|
||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||
|
||||
// Calculate overall score
|
||||
results.Score = r.scorer.CalculateScore(
|
||||
results.Authentication,
|
||||
results.SpamAssassin,
|
||||
results.RBL,
|
||||
results.Content,
|
||||
email,
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
|
|
@ -99,198 +96,225 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
now := time.Now()
|
||||
|
||||
report := &api.Report{
|
||||
Id: utils.UUIDToBase32(reportID),
|
||||
TestId: utils.UUIDToBase32(testID),
|
||||
Id: reportID,
|
||||
TestId: testID,
|
||||
Score: results.Score.OverallScore,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
// Calculate scores directly from analyzers (no more checks array)
|
||||
dnsScore := 0
|
||||
var dnsGrade string
|
||||
if results.DNS != nil {
|
||||
// Extract sender IP from received chain for FCrDNS verification
|
||||
var senderIP string
|
||||
if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 {
|
||||
firstHop := (*results.Headers.ReceivedChain)[0]
|
||||
if firstHop.Ip != nil {
|
||||
senderIP = *firstHop.Ip
|
||||
}
|
||||
}
|
||||
dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP)
|
||||
}
|
||||
|
||||
authScore := 0
|
||||
var authGrade string
|
||||
if results.Authentication != nil {
|
||||
authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
|
||||
}
|
||||
|
||||
contentScore := 0
|
||||
var contentGrade string
|
||||
if results.Content != nil {
|
||||
contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
|
||||
}
|
||||
|
||||
headerScore := 0
|
||||
var headerGrade rune
|
||||
if results.Headers != nil {
|
||||
headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
|
||||
}
|
||||
|
||||
blacklistScore := 0
|
||||
var blacklistGrade string
|
||||
if results.RBL != nil {
|
||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
|
||||
}
|
||||
|
||||
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
|
||||
|
||||
// Combine SpamAssassin and rspamd scores 50/50.
|
||||
// If only one filter ran (the other returns "" grade), use that filter's score alone.
|
||||
var spamScore int
|
||||
var spamGrade string
|
||||
switch {
|
||||
case saGrade == "" && rspamdGrade == "":
|
||||
spamScore = 0
|
||||
spamGrade = ""
|
||||
case saGrade == "":
|
||||
spamScore = rspamdScore
|
||||
spamGrade = rspamdGrade
|
||||
case rspamdGrade == "":
|
||||
spamScore = saScore
|
||||
spamGrade = saGrade
|
||||
default:
|
||||
spamScore = (saScore + rspamdScore) / 2
|
||||
spamGrade = MinGrade(saGrade, rspamdGrade)
|
||||
}
|
||||
|
||||
// Build score summary
|
||||
report.Summary = &api.ScoreSummary{
|
||||
DnsScore: dnsScore,
|
||||
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
|
||||
AuthenticationScore: authScore,
|
||||
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
||||
BlacklistScore: blacklistScore,
|
||||
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
|
||||
ContentScore: contentScore,
|
||||
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
||||
HeaderScore: headerScore,
|
||||
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
|
||||
SpamScore: spamScore,
|
||||
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
|
||||
AuthenticationScore: results.Score.AuthScore,
|
||||
SpamScore: results.Score.SpamScore,
|
||||
BlacklistScore: results.Score.BlacklistScore,
|
||||
ContentScore: results.Score.ContentScore,
|
||||
HeaderScore: results.Score.HeaderScore,
|
||||
}
|
||||
|
||||
// Collect all checks from different analyzers
|
||||
checks := []api.Check{}
|
||||
|
||||
// Authentication checks
|
||||
if results.Authentication != nil {
|
||||
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
|
||||
checks = append(checks, authChecks...)
|
||||
}
|
||||
|
||||
// DNS checks
|
||||
if results.DNS != nil {
|
||||
dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
|
||||
checks = append(checks, dnsChecks...)
|
||||
}
|
||||
|
||||
// RBL checks
|
||||
if results.RBL != nil {
|
||||
rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
|
||||
checks = append(checks, rblChecks...)
|
||||
}
|
||||
|
||||
// SpamAssassin checks
|
||||
if results.SpamAssassin != nil {
|
||||
spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
|
||||
checks = append(checks, spamChecks...)
|
||||
}
|
||||
|
||||
// Content checks
|
||||
if results.Content != nil {
|
||||
contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
|
||||
checks = append(checks, contentChecks...)
|
||||
}
|
||||
|
||||
// Header checks
|
||||
headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
|
||||
checks = append(checks, headerChecks...)
|
||||
|
||||
report.Checks = checks
|
||||
|
||||
// Add authentication results
|
||||
report.Authentication = results.Authentication
|
||||
|
||||
// Add content analysis
|
||||
if results.Content != nil {
|
||||
contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
|
||||
report.ContentAnalysis = contentAnalysis
|
||||
// Add SpamAssassin result
|
||||
if results.SpamAssassin != nil {
|
||||
report.Spamassassin = &api.SpamAssassinResult{
|
||||
Score: float32(results.SpamAssassin.Score),
|
||||
RequiredScore: float32(results.SpamAssassin.RequiredScore),
|
||||
IsSpam: results.SpamAssassin.IsSpam,
|
||||
}
|
||||
|
||||
if len(results.SpamAssassin.Tests) > 0 {
|
||||
report.Spamassassin.Tests = &results.SpamAssassin.Tests
|
||||
}
|
||||
|
||||
if results.SpamAssassin.RawReport != "" {
|
||||
report.Spamassassin.Report = &results.SpamAssassin.RawReport
|
||||
}
|
||||
}
|
||||
|
||||
// Add DNS records
|
||||
if results.DNS != nil {
|
||||
report.DnsResults = results.DNS
|
||||
dnsRecords := r.buildDNSRecords(results.DNS)
|
||||
if len(dnsRecords) > 0 {
|
||||
report.DnsRecords = &dnsRecords
|
||||
}
|
||||
}
|
||||
|
||||
// Add headers results
|
||||
report.HeaderAnalysis = results.Headers
|
||||
|
||||
// Add blacklist checks as a map of IP -> array of BlacklistCheck
|
||||
// Add blacklist checks
|
||||
if results.RBL != nil && len(results.RBL.Checks) > 0 {
|
||||
report.Blacklists = &results.RBL.Checks
|
||||
blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks))
|
||||
for _, check := range results.RBL.Checks {
|
||||
blCheck := api.BlacklistCheck{
|
||||
Ip: check.IP,
|
||||
Rbl: check.RBL,
|
||||
Listed: check.Listed,
|
||||
}
|
||||
if check.Response != "" {
|
||||
blCheck.Response = &check.Response
|
||||
}
|
||||
blacklistChecks = append(blacklistChecks, blCheck)
|
||||
}
|
||||
report.Blacklists = &blacklistChecks
|
||||
}
|
||||
|
||||
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
|
||||
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
|
||||
report.Whitelists = &results.DNSWL.Checks
|
||||
}
|
||||
|
||||
// Add SpamAssassin result with individual deliverability score
|
||||
if results.SpamAssassin != nil {
|
||||
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
|
||||
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
|
||||
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
||||
}
|
||||
report.Spamassassin = results.SpamAssassin
|
||||
|
||||
// Add rspamd result with individual deliverability score
|
||||
if results.Rspamd != nil {
|
||||
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
|
||||
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
|
||||
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
||||
}
|
||||
report.Rspamd = results.Rspamd
|
||||
|
||||
// Add raw headers
|
||||
if results.Email != nil && results.Email.RawHeaders != "" {
|
||||
report.RawHeaders = &results.Email.RawHeaders
|
||||
}
|
||||
|
||||
// Calculate overall score as mean of all category scores
|
||||
categoryScores := []int{
|
||||
report.Summary.DnsScore,
|
||||
report.Summary.AuthenticationScore,
|
||||
report.Summary.BlacklistScore,
|
||||
report.Summary.ContentScore,
|
||||
report.Summary.HeaderScore,
|
||||
report.Summary.SpamScore,
|
||||
}
|
||||
|
||||
var totalScore int
|
||||
var categoryCount int
|
||||
for _, score := range categoryScores {
|
||||
totalScore += score
|
||||
categoryCount++
|
||||
}
|
||||
|
||||
if categoryCount > 0 {
|
||||
report.Score = totalScore / categoryCount
|
||||
} else {
|
||||
report.Score = 0
|
||||
}
|
||||
|
||||
report.Grade = ScoreToReportGrade(report.Score)
|
||||
categoryGrades := []string{
|
||||
string(report.Summary.DnsGrade),
|
||||
string(report.Summary.AuthenticationGrade),
|
||||
string(report.Summary.BlacklistGrade),
|
||||
string(report.Summary.ContentGrade),
|
||||
string(report.Summary.HeaderGrade),
|
||||
string(report.Summary.SpamGrade),
|
||||
}
|
||||
if report.Score >= 100 {
|
||||
hasLessThanA := false
|
||||
|
||||
for _, grade := range categoryGrades {
|
||||
if len(grade) < 1 || grade[0] != 'A' {
|
||||
hasLessThanA = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLessThanA {
|
||||
report.Grade = "A+"
|
||||
}
|
||||
} else {
|
||||
var minusGrade byte = 0
|
||||
for _, grade := range categoryGrades {
|
||||
if len(grade) == 0 {
|
||||
minusGrade = 255
|
||||
break
|
||||
} else if grade[0]-'A' > minusGrade {
|
||||
minusGrade = grade[0] - 'A'
|
||||
}
|
||||
}
|
||||
|
||||
if minusGrade < 255 {
|
||||
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// buildDNSRecords converts DNS analysis results to API DNS records
|
||||
func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord {
|
||||
records := []api.DNSRecord{}
|
||||
|
||||
if dns == nil {
|
||||
return records
|
||||
}
|
||||
|
||||
// MX records
|
||||
if len(dns.MXRecords) > 0 {
|
||||
for _, mx := range dns.MXRecords {
|
||||
status := api.Found
|
||||
if !mx.Valid {
|
||||
if mx.Error != "" {
|
||||
status = api.Missing
|
||||
} else {
|
||||
status = api.Invalid
|
||||
}
|
||||
}
|
||||
|
||||
record := api.DNSRecord{
|
||||
Domain: dns.Domain,
|
||||
RecordType: api.MX,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if mx.Host != "" {
|
||||
value := mx.Host
|
||||
record.Value = &value
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
// SPF record
|
||||
if dns.SPFRecord != nil {
|
||||
status := api.Found
|
||||
if !dns.SPFRecord.Valid {
|
||||
if dns.SPFRecord.Record == "" {
|
||||
status = api.Missing
|
||||
} else {
|
||||
status = api.Invalid
|
||||
}
|
||||
}
|
||||
|
||||
record := api.DNSRecord{
|
||||
Domain: dns.Domain,
|
||||
RecordType: api.SPF,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if dns.SPFRecord.Record != "" {
|
||||
record.Value = &dns.SPFRecord.Record
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
// DKIM records
|
||||
for _, dkim := range dns.DKIMRecords {
|
||||
status := api.Found
|
||||
if !dkim.Valid {
|
||||
if dkim.Record == "" {
|
||||
status = api.Missing
|
||||
} else {
|
||||
status = api.Invalid
|
||||
}
|
||||
}
|
||||
|
||||
record := api.DNSRecord{
|
||||
Domain: dkim.Domain,
|
||||
RecordType: api.DKIM,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if dkim.Record != "" {
|
||||
// Include selector in value for clarity
|
||||
value := dkim.Record
|
||||
record.Value = &value
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
// DMARC record
|
||||
if dns.DMARCRecord != nil {
|
||||
status := api.Found
|
||||
if !dns.DMARCRecord.Valid {
|
||||
if dns.DMARCRecord.Record == "" {
|
||||
status = api.Missing
|
||||
} else {
|
||||
status = api.Invalid
|
||||
}
|
||||
}
|
||||
|
||||
record := api.DNSRecord{
|
||||
Domain: dns.Domain,
|
||||
RecordType: api.DMARC,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if dns.DMARCRecord.Record != "" {
|
||||
record.Value = &dns.DMARCRecord.Record
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// GenerateRawEmail returns the raw email message as a string
|
||||
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
|
||||
if email == nil {
|
||||
|
|
@ -304,3 +328,21 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
|
|||
|
||||
return raw
|
||||
}
|
||||
|
||||
// GetRecommendations returns actionable recommendations based on the score
|
||||
func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
|
||||
if results == nil || results.Score == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return results.Score.Recommendations
|
||||
}
|
||||
|
||||
// GetScoreSummaryText returns a human-readable score summary
|
||||
func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
|
||||
if results == nil || results.Score == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return r.scorer.GetScoreSummary(results.Score)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,16 @@ package analyzer
|
|||
import (
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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)
|
||||
if gen == nil {
|
||||
t.Fatal("Expected report generator, got nil")
|
||||
}
|
||||
|
|
@ -52,10 +53,13 @@ func TestNewReportGenerator(t *testing.T) {
|
|||
if gen.contentAnalyzer == nil {
|
||||
t.Error("contentAnalyzer should not be nil")
|
||||
}
|
||||
if gen.scorer == nil {
|
||||
t.Error("scorer should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEmail(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
email := createTestEmail()
|
||||
|
||||
|
|
@ -72,10 +76,24 @@ func TestAnalyzeEmail(t *testing.T) {
|
|||
if results.Authentication == nil {
|
||||
t.Error("Authentication should not be nil")
|
||||
}
|
||||
|
||||
// SpamAssassin might be nil if headers don't exist
|
||||
// DNS results should exist
|
||||
// RBL results should exist
|
||||
// Content results should exist
|
||||
|
||||
if results.Score == nil {
|
||||
t.Error("Score should not be nil")
|
||||
}
|
||||
|
||||
// Verify score is within bounds
|
||||
if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 {
|
||||
t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReport(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmail()
|
||||
|
|
@ -88,17 +106,15 @@ func TestGenerateReport(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify required fields
|
||||
if report.Id == "" {
|
||||
if report.Id == uuid.Nil {
|
||||
t.Error("Report ID should not be empty")
|
||||
}
|
||||
|
||||
// Convert testID to base32 for comparison
|
||||
expectedTestID := utils.UUIDToBase32(testID)
|
||||
if report.TestId != expectedTestID {
|
||||
t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID)
|
||||
if report.TestId != testID {
|
||||
t.Errorf("TestId = %s, want %s", report.TestId, testID)
|
||||
}
|
||||
|
||||
if report.Score < 0 || report.Score > 100 {
|
||||
if report.Score < 0 || report.Score > 10 {
|
||||
t.Errorf("Score %v is out of bounds", report.Score)
|
||||
}
|
||||
|
||||
|
|
@ -106,31 +122,48 @@ func TestGenerateReport(t *testing.T) {
|
|||
t.Error("Summary should not be nil")
|
||||
}
|
||||
|
||||
// Verify score summary (all scores are 0-100 percentages)
|
||||
if len(report.Checks) == 0 {
|
||||
t.Error("Checks should not be empty")
|
||||
}
|
||||
|
||||
// Verify score summary
|
||||
if report.Summary != nil {
|
||||
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 {
|
||||
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
|
||||
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
|
||||
}
|
||||
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 {
|
||||
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 {
|
||||
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
|
||||
}
|
||||
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 {
|
||||
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 {
|
||||
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
|
||||
}
|
||||
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 {
|
||||
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 {
|
||||
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
|
||||
}
|
||||
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 {
|
||||
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 {
|
||||
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
|
||||
}
|
||||
if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 {
|
||||
t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore)
|
||||
}
|
||||
|
||||
// Verify checks have required fields
|
||||
for i, check := range report.Checks {
|
||||
if string(check.Category) == "" {
|
||||
t.Errorf("Check %d: Category should not be empty", i)
|
||||
}
|
||||
if check.Name == "" {
|
||||
t.Errorf("Check %d: Name should not be empty", i)
|
||||
}
|
||||
if string(check.Status) == "" {
|
||||
t.Errorf("Check %d: Status should not be empty", i)
|
||||
}
|
||||
if check.Message == "" {
|
||||
t.Errorf("Check %d: Message should not be empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
|
|
@ -149,8 +182,101 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSRecords(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dns *DNSResults
|
||||
expectedCount int
|
||||
expectTypes []api.DNSRecordRecordType
|
||||
}{
|
||||
{
|
||||
name: "Nil DNS results",
|
||||
dns: nil,
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Complete DNS results",
|
||||
dns: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
},
|
||||
SPFRecord: &SPFRecord{
|
||||
Record: "v=spf1 include:_spf.example.com -all",
|
||||
Valid: true,
|
||||
},
|
||||
DKIMRecords: []DKIMRecord{
|
||||
{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=DKIM1; k=rsa; p=...",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
DMARCRecord: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=quarantine",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
expectedCount: 4, // MX, SPF, DKIM, DMARC
|
||||
expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC},
|
||||
},
|
||||
{
|
||||
name: "Missing records",
|
||||
dns: &DNSResults{
|
||||
Domain: "example.com",
|
||||
SPFRecord: &SPFRecord{
|
||||
Valid: false,
|
||||
Error: "No SPF record found",
|
||||
},
|
||||
},
|
||||
expectedCount: 1,
|
||||
expectTypes: []api.DNSRecordRecordType{api.SPF},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
records := gen.buildDNSRecords(tt.dns)
|
||||
|
||||
if len(records) != tt.expectedCount {
|
||||
t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount)
|
||||
}
|
||||
|
||||
// Verify expected types are present
|
||||
if tt.expectTypes != nil {
|
||||
foundTypes := make(map[api.DNSRecordRecordType]bool)
|
||||
for _, record := range records {
|
||||
foundTypes[record.RecordType] = true
|
||||
}
|
||||
|
||||
for _, expectedType := range tt.expectTypes {
|
||||
if !foundTypes[expectedType] {
|
||||
t.Errorf("Expected DNS record type %s not found", expectedType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all records have required fields
|
||||
for i, record := range records {
|
||||
if record.Domain == "" {
|
||||
t.Errorf("Record %d: Domain should not be empty", i)
|
||||
}
|
||||
if string(record.RecordType) == "" {
|
||||
t.Errorf("Record %d: RecordType should not be empty", i)
|
||||
}
|
||||
if string(record.Status) == "" {
|
||||
t.Errorf("Record %d: Status should not be empty", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRawEmail(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -190,6 +316,135 @@ func TestGenerateRawEmail(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetRecommendations(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
results *AnalysisResults
|
||||
expectCount int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
expectCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Results with score",
|
||||
results: &AnalysisResults{
|
||||
Score: &ScoringResult{
|
||||
OverallScore: 5.0,
|
||||
Rating: "Fair",
|
||||
AuthScore: 1.5,
|
||||
SpamScore: 1.0,
|
||||
BlacklistScore: 1.5,
|
||||
ContentScore: 0.5,
|
||||
HeaderScore: 0.5,
|
||||
Recommendations: []string{
|
||||
"Improve authentication",
|
||||
"Fix content issues",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
recs := gen.GetRecommendations(tt.results)
|
||||
if len(recs) != tt.expectCount {
|
||||
t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScoreSummaryText(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
results *AnalysisResults
|
||||
expectEmpty bool
|
||||
expectString string
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
expectEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "Results with score",
|
||||
results: &AnalysisResults{
|
||||
Score: &ScoringResult{
|
||||
OverallScore: 8.5,
|
||||
Rating: "Good",
|
||||
AuthScore: 2.5,
|
||||
SpamScore: 1.8,
|
||||
BlacklistScore: 2.0,
|
||||
ContentScore: 1.5,
|
||||
HeaderScore: 0.7,
|
||||
CategoryBreakdown: map[string]CategoryScore{
|
||||
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
|
||||
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
|
||||
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
|
||||
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
|
||||
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectEmpty: false,
|
||||
expectString: "8.5/10",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
summary := gen.GetScoreSummaryText(tt.results)
|
||||
if tt.expectEmpty {
|
||||
if summary != "" {
|
||||
t.Errorf("Expected empty summary, got %q", summary)
|
||||
}
|
||||
} else {
|
||||
if summary == "" {
|
||||
t.Error("Expected non-empty summary")
|
||||
}
|
||||
if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
|
||||
t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCategories(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createComprehensiveTestEmail()
|
||||
results := gen.AnalyzeEmail(email)
|
||||
report := gen.GenerateReport(testID, results)
|
||||
|
||||
// Verify all check categories are present
|
||||
categories := make(map[api.CheckCategory]bool)
|
||||
for _, check := range report.Checks {
|
||||
categories[check.Category] = true
|
||||
}
|
||||
|
||||
expectedCategories := []api.CheckCategory{
|
||||
api.Authentication,
|
||||
api.Dns,
|
||||
api.Headers,
|
||||
}
|
||||
|
||||
for _, cat := range expectedCategories {
|
||||
if !categories[cat] {
|
||||
t.Errorf("Expected category %s not found in checks", cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestEmail() *EmailMessage {
|
||||
|
|
@ -226,3 +481,21 @@ func createTestEmailWithSpamAssassin() *EmailMessage {
|
|||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
|
||||
return email
|
||||
}
|
||||
|
||||
func createComprehensiveTestEmail() *EmailMessage {
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
|
||||
// Add authentication headers
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
|
||||
"example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
|
||||
}
|
||||
|
||||
// Add HTML content
|
||||
email.Parts = append(email.Parts, MessagePart{
|
||||
ContentType: "text/html",
|
||||
Content: "<html><body><p>Test</p><a href='https://example.com'>Link</a></body></html>",
|
||||
IsHTML: true,
|
||||
})
|
||||
|
||||
return email
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||
const (
|
||||
rspamdDefaultRejectThreshold float32 = 15
|
||||
rspamdDefaultAddHeaderThreshold float32 = 6
|
||||
)
|
||||
|
||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||
type RspamdAnalyzer struct{}
|
||||
|
||||
// NewRspamdAnalyzer creates a new rspamd analyzer
|
||||
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
||||
return &RspamdAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||
headers := email.GetRspamdHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
|
||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
||||
result.Report = &report
|
||||
a.parseSpamdResult(spamdResult, result)
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Score as override/fallback for score
|
||||
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
|
||||
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Server
|
||||
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
|
||||
server := strings.TrimSpace(serverHeader)
|
||||
result.Server = &server
|
||||
}
|
||||
|
||||
// Derive IsSpam from score vs reject threshold.
|
||||
if result.Threshold > 0 {
|
||||
result.IsSpam = result.Score >= result.Threshold
|
||||
} else {
|
||||
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseSpamdResult parses the X-Spamd-Result header
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
||||
// Extract score and threshold from the first line
|
||||
// e.g. "default: False [-3.91 / 15.00]"
|
||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
|
||||
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
|
||||
result.Threshold = float32(threshold)
|
||||
|
||||
// No threshold? use default AddHeaderThreshold
|
||||
if result.Threshold <= 0 {
|
||||
result.Threshold = rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
|
||||
firstLine := strings.SplitN(header, ";", 2)[0]
|
||||
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
|
||||
result.IsSpam = true
|
||||
}
|
||||
|
||||
// Parse symbols: SYMBOL(score)[params]
|
||||
// Each symbol entry is separated by ";", so within each part we use a
|
||||
// greedy match to capture params that may contain nested brackets.
|
||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
matches := symbolRe.FindStringSubmatch(part)
|
||||
if len(matches) > 2 {
|
||||
name := matches[1]
|
||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||
sym := api.RspamdSymbol{
|
||||
Name: name,
|
||||
Score: float32(score),
|
||||
}
|
||||
if len(matches) > 3 && matches[3] != "" {
|
||||
params := matches[3]
|
||||
sym.Params = ¶ms
|
||||
}
|
||||
result.Symbols[name] = sym
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
||||
if result == nil {
|
||||
return 100, "" // rspamd not installed
|
||||
}
|
||||
|
||||
threshold := result.Threshold
|
||||
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
|
||||
|
||||
if percentage > 100 {
|
||||
return 100, "A+"
|
||||
} else if percentage < 0 {
|
||||
return 0, "F"
|
||||
}
|
||||
|
||||
// Linear scale between 0 and threshold
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpamdResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedSymbols map[string]float32
|
||||
expectedSymParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Clean email negative score",
|
||||
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"DATE_IN_PAST": 0.10,
|
||||
"ALL_TRUSTED": -1.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ALL_TRUSTED": "trusted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Spam email True flag",
|
||||
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
||||
expectedScore: 16.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{
|
||||
"BAYES_99": 5.00,
|
||||
"SPOOFED_SENDER": 3.50,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"BAYES_99": "1.00",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold uses default",
|
||||
header: "default: False [1.00 / 0.00]",
|
||||
expectedScore: 1.00,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Symbol without params",
|
||||
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
||||
expectedScore: 2.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"MISSING_DATE": 1.00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Case-insensitive true flag",
|
||||
header: "default: true [8.00 / 6.00]",
|
||||
expectedScore: 8.00,
|
||||
expectedThreshold: 6.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold with symbols containing nested brackets in params",
|
||||
header: "default: False [0.90 / 0.00];\n" +
|
||||
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
|
||||
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
|
||||
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
|
||||
expectedScore: 0.90,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"ARC_REJECT": 1.00,
|
||||
"MIME_GOOD": -0.10,
|
||||
"MIME_TRACE": 0.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
|
||||
"MIME_GOOD": "multipart/alternative,text/plain",
|
||||
"MIME_TRACE": "0:+,1:+,2:~",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
analyzer.parseSpamdResult(tt.header, result)
|
||||
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
for symName, expectedScore := range tt.expectedSymbols {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Score != expectedScore {
|
||||
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
||||
}
|
||||
}
|
||||
for symName, expectedParam := range tt.expectedSymParams {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found for params check", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Params == nil {
|
||||
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
||||
} else if *sym.Params != expectedParam {
|
||||
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRspamd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedServer *string
|
||||
expectedSymCount int
|
||||
}{
|
||||
{
|
||||
name: "Full headers clean email",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
||||
"X-Rspamd-Score": "-3.91",
|
||||
"X-Rspamd-Server": "mail.example.com",
|
||||
},
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "X-Rspamd-Score overrides spamd result score",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
||||
"X-Rspamd-Score": "3.50",
|
||||
},
|
||||
expectedScore: 3.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "Spam email above threshold",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
||||
"X-Rspamd-Score": "16.00",
|
||||
},
|
||||
expectedScore: 16.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "2.00",
|
||||
},
|
||||
expectedScore: 2.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "7.00",
|
||||
},
|
||||
expectedScore: 7.00,
|
||||
expectedIsSpam: true,
|
||||
},
|
||||
{
|
||||
name: "Server header is trimmed",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "1.00",
|
||||
"X-Rspamd-Server": " rspamd-01 ",
|
||||
},
|
||||
expectedScore: 1.00,
|
||||
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
for k, v := range tt.headers {
|
||||
email.Header[k] = []string{v}
|
||||
}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
if tt.expectedServer != nil {
|
||||
if result.Server == nil {
|
||||
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
||||
} else if *result.Server != *tt.expectedServer {
|
||||
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
||||
}
|
||||
}
|
||||
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
||||
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRspamdScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *api.RspamdResult
|
||||
expectedScore int
|
||||
expectedGrade string
|
||||
}{
|
||||
{
|
||||
name: "Nil result (rspamd not installed)",
|
||||
result: nil,
|
||||
expectedScore: 100,
|
||||
expectedGrade: "",
|
||||
},
|
||||
{
|
||||
name: "Score well below threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: -3.91,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A+",
|
||||
},
|
||||
{
|
||||
name: "Score at zero",
|
||||
result: &api.RspamdResult{
|
||||
Score: 0,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A",
|
||||
},
|
||||
{
|
||||
name: "Score at threshold (half of 2*threshold)",
|
||||
result: &api.RspamdResult{
|
||||
Score: 15.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
||||
expectedScore: 50,
|
||||
},
|
||||
{
|
||||
name: "Score above 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 31.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
{
|
||||
name: "Score exactly at 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 30.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(30*100/30) = 100 - 100 = 0
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
||||
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
||||
BAYES_HAM(-3.00)[99%];
|
||||
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
||||
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
||||
FROM_HAS_DN(0.00)[];
|
||||
MIME_GOOD(-0.10)[text/plain];
|
||||
X-Rspamd-Score: -3.91
|
||||
X-Rspamd-Server: rspamd-01.example.com
|
||||
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
||||
From: sender@example.com
|
||||
To: test@happydomain.org
|
||||
Subject: Test email
|
||||
Message-ID: <test123@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain
|
||||
|
||||
Hello world`
|
||||
|
||||
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.IsSpam {
|
||||
t.Error("Expected IsSpam=false")
|
||||
}
|
||||
if result.Score != -3.91 {
|
||||
t.Errorf("Score = %v, want -3.91", result.Score)
|
||||
}
|
||||
if result.Threshold != 15.00 {
|
||||
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
||||
}
|
||||
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
||||
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
||||
}
|
||||
|
||||
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
||||
for _, sym := range expectedSymbols {
|
||||
if _, ok := result.Symbols[sym]; !ok {
|
||||
t.Errorf("Symbol %s not found", sym)
|
||||
}
|
||||
}
|
||||
|
||||
score, _ := analyzer.CalculateRspamdScore(result)
|
||||
if score != 100 {
|
||||
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -22,78 +22,524 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
||||
func ScoreToGrade(score int) string {
|
||||
// DeliverabilityScorer aggregates all analysis results and computes overall score
|
||||
type DeliverabilityScorer struct{}
|
||||
|
||||
// NewDeliverabilityScorer creates a new deliverability scorer
|
||||
func NewDeliverabilityScorer() *DeliverabilityScorer {
|
||||
return &DeliverabilityScorer{}
|
||||
}
|
||||
|
||||
// ScoringResult represents the complete scoring result
|
||||
type ScoringResult struct {
|
||||
OverallScore float32
|
||||
Rating string // Excellent, Good, Fair, Poor, Critical
|
||||
AuthScore float32
|
||||
SpamScore float32
|
||||
BlacklistScore float32
|
||||
ContentScore float32
|
||||
HeaderScore float32
|
||||
Recommendations []string
|
||||
CategoryBreakdown map[string]CategoryScore
|
||||
}
|
||||
|
||||
// CategoryScore represents score breakdown for a category
|
||||
type CategoryScore struct {
|
||||
Score float32
|
||||
MaxScore float32
|
||||
Percentage float32
|
||||
Status string // Pass, Warn, Fail
|
||||
}
|
||||
|
||||
// CalculateScore computes the overall deliverability score from all analyzers
|
||||
func (s *DeliverabilityScorer) CalculateScore(
|
||||
authResults *api.AuthenticationResults,
|
||||
spamResult *SpamAssassinResult,
|
||||
rblResults *RBLResults,
|
||||
contentResults *ContentResults,
|
||||
email *EmailMessage,
|
||||
) *ScoringResult {
|
||||
result := &ScoringResult{
|
||||
CategoryBreakdown: make(map[string]CategoryScore),
|
||||
Recommendations: []string{},
|
||||
}
|
||||
|
||||
// Calculate individual scores
|
||||
result.AuthScore = s.GetAuthenticationScore(authResults)
|
||||
|
||||
spamAnalyzer := NewSpamAssassinAnalyzer()
|
||||
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
|
||||
|
||||
rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs)
|
||||
result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults)
|
||||
|
||||
contentAnalyzer := NewContentAnalyzer(10 * time.Second)
|
||||
result.ContentScore = contentAnalyzer.GetContentScore(contentResults)
|
||||
|
||||
// Calculate header quality score
|
||||
result.HeaderScore = s.calculateHeaderScore(email)
|
||||
|
||||
// Calculate overall score (out of 10)
|
||||
result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
|
||||
|
||||
// Ensure score is within bounds
|
||||
if result.OverallScore > 10.0 {
|
||||
result.OverallScore = 10.0
|
||||
}
|
||||
if result.OverallScore < 0.0 {
|
||||
result.OverallScore = 0.0
|
||||
}
|
||||
|
||||
// Determine rating
|
||||
result.Rating = s.determineRating(result.OverallScore)
|
||||
|
||||
// Build category breakdown
|
||||
result.CategoryBreakdown["Authentication"] = CategoryScore{
|
||||
Score: result.AuthScore,
|
||||
MaxScore: 3.0,
|
||||
Percentage: (result.AuthScore / 3.0) * 100,
|
||||
Status: s.getCategoryStatus(result.AuthScore, 3.0),
|
||||
}
|
||||
|
||||
result.CategoryBreakdown["Spam Filters"] = CategoryScore{
|
||||
Score: result.SpamScore,
|
||||
MaxScore: 2.0,
|
||||
Percentage: (result.SpamScore / 2.0) * 100,
|
||||
Status: s.getCategoryStatus(result.SpamScore, 2.0),
|
||||
}
|
||||
|
||||
result.CategoryBreakdown["Blacklists"] = CategoryScore{
|
||||
Score: result.BlacklistScore,
|
||||
MaxScore: 2.0,
|
||||
Percentage: (result.BlacklistScore / 2.0) * 100,
|
||||
Status: s.getCategoryStatus(result.BlacklistScore, 2.0),
|
||||
}
|
||||
|
||||
result.CategoryBreakdown["Content Quality"] = CategoryScore{
|
||||
Score: result.ContentScore,
|
||||
MaxScore: 2.0,
|
||||
Percentage: (result.ContentScore / 2.0) * 100,
|
||||
Status: s.getCategoryStatus(result.ContentScore, 2.0),
|
||||
}
|
||||
|
||||
result.CategoryBreakdown["Email Structure"] = CategoryScore{
|
||||
Score: result.HeaderScore,
|
||||
MaxScore: 1.0,
|
||||
Percentage: (result.HeaderScore / 1.0) * 100,
|
||||
Status: s.getCategoryStatus(result.HeaderScore, 1.0),
|
||||
}
|
||||
|
||||
// Generate recommendations
|
||||
result.Recommendations = s.generateRecommendations(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// calculateHeaderScore evaluates email structural quality (0-1 point)
|
||||
func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 {
|
||||
if email == nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
score := float32(0.0)
|
||||
requiredHeaders := 0
|
||||
presentHeaders := 0
|
||||
|
||||
// Check required headers (RFC 5322)
|
||||
headers := map[string]bool{
|
||||
"From": false,
|
||||
"Date": false,
|
||||
"Message-ID": false,
|
||||
}
|
||||
|
||||
for header := range headers {
|
||||
requiredHeaders++
|
||||
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
|
||||
headers[header] = true
|
||||
presentHeaders++
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on required headers (0.4 points)
|
||||
if presentHeaders == requiredHeaders {
|
||||
score += 0.4
|
||||
} else {
|
||||
score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders))
|
||||
}
|
||||
|
||||
// Check recommended headers (0.3 points)
|
||||
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
|
||||
recommendedPresent := 0
|
||||
for _, header := range recommendedHeaders {
|
||||
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
|
||||
recommendedPresent++
|
||||
}
|
||||
}
|
||||
score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))
|
||||
|
||||
// Check for proper MIME structure (0.2 points)
|
||||
if len(email.Parts) > 0 {
|
||||
score += 0.2
|
||||
}
|
||||
|
||||
// Check Message-ID format (0.1 points)
|
||||
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
|
||||
if s.isValidMessageID(messageID) {
|
||||
score += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed 1.0
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// isValidMessageID checks if a Message-ID has proper format
|
||||
func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool {
|
||||
// Basic check: should be in format <...@...>
|
||||
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove angle brackets
|
||||
messageID = strings.TrimPrefix(messageID, "<")
|
||||
messageID = strings.TrimSuffix(messageID, ">")
|
||||
|
||||
// Should contain @ symbol
|
||||
if !strings.Contains(messageID, "@") {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(messageID, "@")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Both parts should be non-empty
|
||||
return len(parts[0]) > 0 && len(parts[1]) > 0
|
||||
}
|
||||
|
||||
// determineRating determines the rating based on overall score
|
||||
func (s *DeliverabilityScorer) determineRating(score float32) string {
|
||||
switch {
|
||||
case score > 100:
|
||||
return "A+"
|
||||
case score >= 95:
|
||||
return "A"
|
||||
case score >= 85:
|
||||
return "B"
|
||||
case score >= 75:
|
||||
return "C"
|
||||
case score >= 65:
|
||||
return "D"
|
||||
case score >= 50:
|
||||
return "E"
|
||||
case score >= 9.0:
|
||||
return "Excellent"
|
||||
case score >= 7.0:
|
||||
return "Good"
|
||||
case score >= 5.0:
|
||||
return "Fair"
|
||||
case score >= 3.0:
|
||||
return "Poor"
|
||||
default:
|
||||
return "F"
|
||||
return "Critical"
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation
|
||||
func ScoreToGradeKind(score int) string {
|
||||
// getCategoryStatus determines status for a category
|
||||
func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
|
||||
percentage := (score / maxScore) * 100
|
||||
|
||||
switch {
|
||||
case score > 100:
|
||||
return "A+"
|
||||
case score >= 90:
|
||||
return "A"
|
||||
case score >= 80:
|
||||
return "B"
|
||||
case score >= 60:
|
||||
return "C"
|
||||
case score >= 45:
|
||||
return "D"
|
||||
case score >= 30:
|
||||
return "E"
|
||||
case percentage >= 80.0:
|
||||
return "Pass"
|
||||
case percentage >= 50.0:
|
||||
return "Warn"
|
||||
default:
|
||||
return "F"
|
||||
return "Fail"
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
|
||||
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||
return api.ReportGrade(ScoreToGrade(score))
|
||||
// generateRecommendations creates actionable recommendations based on scores
|
||||
func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string {
|
||||
var recommendations []string
|
||||
|
||||
// Authentication recommendations
|
||||
if result.AuthScore < 2.0 {
|
||||
recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records")
|
||||
} else if result.AuthScore < 3.0 {
|
||||
recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability")
|
||||
}
|
||||
|
||||
// Spam recommendations
|
||||
if result.SpamScore < 1.0 {
|
||||
recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns")
|
||||
} else if result.SpamScore < 1.5 {
|
||||
recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues")
|
||||
}
|
||||
|
||||
// Blacklist recommendations
|
||||
if result.BlacklistScore < 1.0 {
|
||||
recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation")
|
||||
} else if result.BlacklistScore < 2.0 {
|
||||
recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices")
|
||||
}
|
||||
|
||||
// Content recommendations
|
||||
if result.ContentScore < 1.0 {
|
||||
recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure")
|
||||
} else if result.ContentScore < 1.5 {
|
||||
recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency")
|
||||
}
|
||||
|
||||
// Header recommendations
|
||||
if result.HeaderScore < 0.5 {
|
||||
recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)")
|
||||
} else if result.HeaderScore < 1.0 {
|
||||
recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present")
|
||||
}
|
||||
|
||||
// Overall recommendations based on rating
|
||||
if result.Rating == "Excellent" {
|
||||
recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices")
|
||||
} else if result.Rating == "Critical" {
|
||||
recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam")
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||
func gradeRank(grade string) int {
|
||||
switch grade {
|
||||
case "A+":
|
||||
return 6
|
||||
case "A":
|
||||
return 5
|
||||
case "B":
|
||||
return 4
|
||||
case "C":
|
||||
return 3
|
||||
case "D":
|
||||
return 2
|
||||
case "E":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
// GenerateHeaderChecks creates checks for email header quality
|
||||
func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if email == nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
// Required headers check
|
||||
checks = append(checks, s.generateRequiredHeadersCheck(email))
|
||||
|
||||
// Recommended headers check
|
||||
checks = append(checks, s.generateRecommendedHeadersCheck(email))
|
||||
|
||||
// Message-ID check
|
||||
checks = append(checks, s.generateMessageIDCheck(email))
|
||||
|
||||
// MIME structure check
|
||||
checks = append(checks, s.generateMIMEStructureCheck(email))
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// MinGrade returns the minimal (worse) grade between the two given grades
|
||||
func MinGrade(a, b string) string {
|
||||
if gradeRank(a) <= gradeRank(b) {
|
||||
return a
|
||||
// generateRequiredHeadersCheck checks for required RFC 5322 headers
|
||||
func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Headers,
|
||||
Name: "Required Headers",
|
||||
}
|
||||
return b
|
||||
|
||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||
missing := []string{}
|
||||
|
||||
for _, header := range requiredHeaders {
|
||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||
missing = append(missing, header)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.4
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = "All required headers are present"
|
||||
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
|
||||
} else {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
|
||||
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
|
||||
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateRecommendedHeadersCheck checks for recommended headers
|
||||
func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Headers,
|
||||
Name: "Recommended Headers",
|
||||
}
|
||||
|
||||
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
|
||||
missing := []string{}
|
||||
|
||||
for _, header := range recommendedHeaders {
|
||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||
missing = append(missing, header)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.3
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = "All recommended headers are present"
|
||||
check.Advice = api.PtrTo("Your email includes all recommended headers")
|
||||
} else if len(missing) < len(recommendedHeaders) {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.15
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
|
||||
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
|
||||
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Message = "Missing all recommended headers"
|
||||
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateMessageIDCheck validates Message-ID header
|
||||
func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Headers,
|
||||
Name: "Message-ID Format",
|
||||
}
|
||||
|
||||
messageID := email.GetHeaderValue("Message-ID")
|
||||
|
||||
if messageID == "" {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Message = "Message-ID header is missing"
|
||||
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
|
||||
} else if !s.isValidMessageID(messageID) {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.05
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Message = "Message-ID format is invalid"
|
||||
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
|
||||
check.Details = &messageID
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.1
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = "Message-ID is properly formatted"
|
||||
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
|
||||
check.Details = &messageID
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateMIMEStructureCheck validates MIME structure
|
||||
func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Headers,
|
||||
Name: "MIME Structure",
|
||||
}
|
||||
|
||||
if len(email.Parts) == 0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Message = "No MIME parts detected"
|
||||
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.2
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
|
||||
check.Advice = api.PtrTo("Your email has proper MIME structure")
|
||||
|
||||
// Add details about parts
|
||||
partTypes := []string{}
|
||||
for _, part := range email.Parts {
|
||||
if part.ContentType != "" {
|
||||
partTypes = append(partTypes, part.ContentType)
|
||||
}
|
||||
}
|
||||
if len(partTypes) > 0 {
|
||||
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
|
||||
check.Details = &details
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// GetScoreSummary generates a human-readable summary of the score
|
||||
func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
|
||||
var summary strings.Builder
|
||||
|
||||
summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating))
|
||||
summary.WriteString("Category Breakdown:\n")
|
||||
summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n",
|
||||
result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status))
|
||||
summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n",
|
||||
result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status))
|
||||
summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n",
|
||||
result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status))
|
||||
summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n",
|
||||
result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status))
|
||||
summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n",
|
||||
result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status))
|
||||
|
||||
if len(result.Recommendations) > 0 {
|
||||
summary.WriteString("\nRecommendations:\n")
|
||||
for _, rec := range result.Recommendations {
|
||||
summary.WriteString(fmt.Sprintf(" %s\n", rec))
|
||||
}
|
||||
}
|
||||
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
||||
func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
|
||||
var score float32 = 0.0
|
||||
|
||||
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
|
||||
if results.Spf != nil {
|
||||
switch results.Spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 1.0
|
||||
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
|
||||
score += 0.5
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM: 1 point for at least one pass
|
||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||
for _, dkim := range *results.Dkim {
|
||||
if dkim.Result == api.AuthResultResultPass {
|
||||
score += 1.0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC: 1 point for pass
|
||||
if results.Dmarc != nil {
|
||||
switch results.Dmarc.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at 3 points maximum
|
||||
if score > 3.0 {
|
||||
score = 3.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
|
|
|||
762
pkg/analyzer/scoring_test.go
Normal file
762
pkg/analyzer/scoring_test.go
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestNewDeliverabilityScorer(t *testing.T) {
|
||||
scorer := NewDeliverabilityScorer()
|
||||
if scorer == nil {
|
||||
t.Fatal("Expected scorer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidMessageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messageID string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid Message-ID",
|
||||
messageID: "<abc123@example.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid with UUID",
|
||||
messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Missing angle brackets",
|
||||
messageID: "abc123@example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Missing @ symbol",
|
||||
messageID: "<abc123example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Multiple @ symbols",
|
||||
messageID: "<abc@123@example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty local part",
|
||||
messageID: "<@example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty domain part",
|
||||
messageID: "<abc123@>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
messageID: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scorer.isValidMessageID(tt.messageID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHeaderScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
minScore float32
|
||||
maxScore float32
|
||||
}{
|
||||
{
|
||||
name: "Nil email",
|
||||
email: nil,
|
||||
minScore: 0.0,
|
||||
maxScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Perfect headers",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"To": "recipient@example.com",
|
||||
"Subject": "Test",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
"Reply-To": "reply@example.com",
|
||||
}),
|
||||
MessageID: "<abc123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minScore: 0.7,
|
||||
maxScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Missing required headers",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"Subject": "Test",
|
||||
}),
|
||||
},
|
||||
minScore: 0.0,
|
||||
maxScore: 0.4,
|
||||
},
|
||||
{
|
||||
name: "Required only, no recommended",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
}),
|
||||
MessageID: "<abc123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minScore: 0.4,
|
||||
maxScore: 0.8,
|
||||
},
|
||||
{
|
||||
name: "Invalid Message-ID format",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "invalid-message-id",
|
||||
"Subject": "Test",
|
||||
"To": "recipient@example.com",
|
||||
"Reply-To": "reply@example.com",
|
||||
}),
|
||||
MessageID: "invalid-message-id",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minScore: 0.7,
|
||||
maxScore: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := scorer.calculateHeaderScore(tt.email)
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineRating(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
score float32
|
||||
expected string
|
||||
}{
|
||||
{name: "Excellent - 10.0", score: 10.0, expected: "Excellent"},
|
||||
{name: "Excellent - 9.5", score: 9.5, expected: "Excellent"},
|
||||
{name: "Excellent - 9.0", score: 9.0, expected: "Excellent"},
|
||||
{name: "Good - 8.5", score: 8.5, expected: "Good"},
|
||||
{name: "Good - 7.0", score: 7.0, expected: "Good"},
|
||||
{name: "Fair - 6.5", score: 6.5, expected: "Fair"},
|
||||
{name: "Fair - 5.0", score: 5.0, expected: "Fair"},
|
||||
{name: "Poor - 4.5", score: 4.5, expected: "Poor"},
|
||||
{name: "Poor - 3.0", score: 3.0, expected: "Poor"},
|
||||
{name: "Critical - 2.5", score: 2.5, expected: "Critical"},
|
||||
{name: "Critical - 0.0", score: 0.0, expected: "Critical"},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scorer.determineRating(tt.score)
|
||||
if result != tt.expected {
|
||||
t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCategoryStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
score float32
|
||||
maxScore float32
|
||||
expected string
|
||||
}{
|
||||
{name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"},
|
||||
{name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"},
|
||||
{name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"},
|
||||
{name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"},
|
||||
{name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"},
|
||||
{name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"},
|
||||
{name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scorer.getCategoryStatus(tt.score, tt.maxScore)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authResults *api.AuthenticationResults
|
||||
spamResult *SpamAssassinResult
|
||||
rblResults *RBLResults
|
||||
contentResults *ContentResults
|
||||
email *EmailMessage
|
||||
minScore float32
|
||||
maxScore float32
|
||||
expectedRating string
|
||||
}{
|
||||
{
|
||||
name: "Perfect email",
|
||||
authResults: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: &api.AuthResult{Result: api.AuthResultResultPass},
|
||||
},
|
||||
spamResult: &SpamAssassinResult{
|
||||
Score: -1.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
rblResults: &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{IP: "192.0.2.1", Listed: false},
|
||||
},
|
||||
},
|
||||
contentResults: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: true,
|
||||
TextPlainRatio: 0.8,
|
||||
ImageTextRatio: 3.0,
|
||||
},
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"To": "recipient@example.com",
|
||||
"Subject": "Test",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
"Reply-To": "reply@example.com",
|
||||
}),
|
||||
MessageID: "<abc123@example.com>",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minScore: 9.0,
|
||||
maxScore: 10.0,
|
||||
expectedRating: "Excellent",
|
||||
},
|
||||
{
|
||||
name: "Poor email - auth issues",
|
||||
authResults: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{Result: api.AuthResultResultFail},
|
||||
Dkim: &[]api.AuthResult{},
|
||||
Dmarc: nil,
|
||||
},
|
||||
spamResult: &SpamAssassinResult{
|
||||
Score: 8.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
rblResults: &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{
|
||||
IP: "192.0.2.1",
|
||||
RBL: "zen.spamhaus.org",
|
||||
Listed: true,
|
||||
},
|
||||
},
|
||||
ListedCount: 1,
|
||||
},
|
||||
contentResults: &ContentResults{
|
||||
HTMLValid: false,
|
||||
Links: []LinkCheck{{Valid: true, Status: 404}},
|
||||
HasUnsubscribe: false,
|
||||
},
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
}),
|
||||
},
|
||||
minScore: 0.0,
|
||||
maxScore: 5.0,
|
||||
expectedRating: "Poor",
|
||||
},
|
||||
{
|
||||
name: "Average email",
|
||||
authResults: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: nil,
|
||||
},
|
||||
spamResult: &SpamAssassinResult{
|
||||
Score: 4.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
rblResults: &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{IP: "192.0.2.1", Listed: false},
|
||||
},
|
||||
},
|
||||
contentResults: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
HasUnsubscribe: false,
|
||||
},
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
}),
|
||||
MessageID: "<abc123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minScore: 6.0,
|
||||
maxScore: 9.0,
|
||||
expectedRating: "Good",
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scorer.CalculateScore(
|
||||
tt.authResults,
|
||||
tt.spamResult,
|
||||
tt.rblResults,
|
||||
tt.contentResults,
|
||||
tt.email,
|
||||
)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected result, got nil")
|
||||
}
|
||||
|
||||
// Check overall score
|
||||
if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore {
|
||||
t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore)
|
||||
}
|
||||
|
||||
// Check rating
|
||||
if result.Rating != tt.expectedRating {
|
||||
t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating)
|
||||
}
|
||||
|
||||
// Verify score is within bounds
|
||||
if result.OverallScore < 0.0 || result.OverallScore > 10.0 {
|
||||
t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore)
|
||||
}
|
||||
|
||||
// Verify category breakdown exists
|
||||
if len(result.CategoryBreakdown) != 5 {
|
||||
t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown))
|
||||
}
|
||||
|
||||
// Verify recommendations exist
|
||||
if len(result.Recommendations) == 0 && result.Rating != "Excellent" {
|
||||
t.Error("Expected recommendations for non-excellent rating")
|
||||
}
|
||||
|
||||
// Verify category scores add up to overall score
|
||||
totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
|
||||
if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 {
|
||||
t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)",
|
||||
totalCategoryScore, result.OverallScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRecommendations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *ScoringResult
|
||||
expectedMinCount int
|
||||
shouldContainKeyword string
|
||||
}{
|
||||
{
|
||||
name: "Excellent - minimal recommendations",
|
||||
result: &ScoringResult{
|
||||
OverallScore: 9.5,
|
||||
Rating: "Excellent",
|
||||
AuthScore: 3.0,
|
||||
SpamScore: 2.0,
|
||||
BlacklistScore: 2.0,
|
||||
ContentScore: 2.0,
|
||||
HeaderScore: 1.0,
|
||||
},
|
||||
expectedMinCount: 1,
|
||||
shouldContainKeyword: "Excellent",
|
||||
},
|
||||
{
|
||||
name: "Critical - many recommendations",
|
||||
result: &ScoringResult{
|
||||
OverallScore: 1.0,
|
||||
Rating: "Critical",
|
||||
AuthScore: 0.5,
|
||||
SpamScore: 0.0,
|
||||
BlacklistScore: 0.0,
|
||||
ContentScore: 0.3,
|
||||
HeaderScore: 0.2,
|
||||
},
|
||||
expectedMinCount: 5,
|
||||
shouldContainKeyword: "Critical",
|
||||
},
|
||||
{
|
||||
name: "Poor authentication",
|
||||
result: &ScoringResult{
|
||||
OverallScore: 5.0,
|
||||
Rating: "Fair",
|
||||
AuthScore: 1.5,
|
||||
SpamScore: 2.0,
|
||||
BlacklistScore: 2.0,
|
||||
ContentScore: 1.5,
|
||||
HeaderScore: 1.0,
|
||||
},
|
||||
expectedMinCount: 1,
|
||||
shouldContainKeyword: "authentication",
|
||||
},
|
||||
{
|
||||
name: "Blacklist issues",
|
||||
result: &ScoringResult{
|
||||
OverallScore: 4.0,
|
||||
Rating: "Poor",
|
||||
AuthScore: 3.0,
|
||||
SpamScore: 2.0,
|
||||
BlacklistScore: 0.5,
|
||||
ContentScore: 1.5,
|
||||
HeaderScore: 1.0,
|
||||
},
|
||||
expectedMinCount: 1,
|
||||
shouldContainKeyword: "blacklist",
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
recommendations := scorer.generateRecommendations(tt.result)
|
||||
|
||||
if len(recommendations) < tt.expectedMinCount {
|
||||
t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount)
|
||||
}
|
||||
|
||||
// Check if expected keyword appears in any recommendation
|
||||
found := false
|
||||
for _, rec := range recommendations {
|
||||
if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("No recommendation contains keyword %q. Recommendations: %v",
|
||||
tt.shouldContainKeyword, recommendations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequiredHeadersCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore float32
|
||||
}{
|
||||
{
|
||||
name: "All required headers present",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
}),
|
||||
From: &mail.Address{Address: "sender@example.com"},
|
||||
MessageID: "<abc123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0.4,
|
||||
},
|
||||
{
|
||||
name: "Missing all required headers",
|
||||
email: &EmailMessage{
|
||||
Header: make(mail.Header),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Missing some required headers",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
}),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := scorer.generateRequiredHeadersCheck(tt.email)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||
}
|
||||
if check.Category != api.Headers {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMessageIDCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messageID string
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "Valid Message-ID",
|
||||
messageID: "<abc123@example.com>",
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "Invalid Message-ID format",
|
||||
messageID: "invalid-message-id",
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
{
|
||||
name: "Missing Message-ID",
|
||||
messageID: "",
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"Message-ID": tt.messageID,
|
||||
}),
|
||||
}
|
||||
|
||||
check := scorer.generateMessageIDCheck(email)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Headers {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMIMEStructureCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parts []MessagePart
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "With MIME parts",
|
||||
parts: []MessagePart{
|
||||
{ContentType: "text/plain", Content: "test"},
|
||||
{ContentType: "text/html", Content: "<p>test</p>"},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "No MIME parts",
|
||||
parts: []MessagePart{},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{
|
||||
Header: make(mail.Header),
|
||||
Parts: tt.parts,
|
||||
}
|
||||
|
||||
check := scorer.generateMIMEStructureCheck(email)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHeaderChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil email",
|
||||
email: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "Complete email",
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"To": "recipient@example.com",
|
||||
"Subject": "Test",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
"Reply-To": "reply@example.com",
|
||||
}),
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minChecks: 4, // Required, Recommended, Message-ID, MIME
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := scorer.GenerateHeaderChecks(tt.email)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the Headers category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Headers {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScoreSummary(t *testing.T) {
|
||||
result := &ScoringResult{
|
||||
OverallScore: 8.5,
|
||||
Rating: "Good",
|
||||
AuthScore: 2.5,
|
||||
SpamScore: 1.8,
|
||||
BlacklistScore: 2.0,
|
||||
ContentScore: 1.5,
|
||||
HeaderScore: 0.7,
|
||||
CategoryBreakdown: map[string]CategoryScore{
|
||||
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
|
||||
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
|
||||
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
|
||||
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
|
||||
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
|
||||
},
|
||||
Recommendations: []string{
|
||||
"Improve content quality",
|
||||
"Add more headers",
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
summary := scorer.GetScoreSummary(result)
|
||||
|
||||
// Check that summary contains key information
|
||||
if !strings.Contains(summary, "8.5") {
|
||||
t.Error("Summary should contain overall score")
|
||||
}
|
||||
if !strings.Contains(summary, "Good") {
|
||||
t.Error("Summary should contain rating")
|
||||
}
|
||||
if !strings.Contains(summary, "Authentication") {
|
||||
t.Error("Summary should contain category names")
|
||||
}
|
||||
if !strings.Contains(summary, "Recommendations") {
|
||||
t.Error("Summary should contain recommendations section")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create mail.Header with specific fields
|
||||
func createHeaderWithFields(fields map[string]string) mail.Header {
|
||||
header := make(mail.Header)
|
||||
for key, value := range fields {
|
||||
if value != "" {
|
||||
// Use canonical MIME header key format
|
||||
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
|
||||
header[canonicalKey] = []string{value}
|
||||
}
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"math"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -38,26 +38,44 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
|
|||
return &SpamAssassinAnalyzer{}
|
||||
}
|
||||
|
||||
// SpamAssassinResult represents parsed SpamAssassin results
|
||||
type SpamAssassinResult struct {
|
||||
IsSpam bool
|
||||
Score float64
|
||||
RequiredScore float64
|
||||
Tests []string
|
||||
TestDetails map[string]SpamTestDetail
|
||||
Version string
|
||||
RawReport string
|
||||
}
|
||||
|
||||
// SpamTestDetail contains details about a specific spam test
|
||||
type SpamTestDetail struct {
|
||||
Name string
|
||||
Score float64
|
||||
Description string
|
||||
}
|
||||
|
||||
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
||||
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
|
||||
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult {
|
||||
headers := email.GetSpamAssassinHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
result := &SpamAssassinResult{
|
||||
TestDetails: make(map[string]SpamTestDetail),
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Parse X-Spam-Score header (as fallback if not in X-Spam-Status)
|
||||
if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 {
|
||||
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
result.Score = score
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,13 +86,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
|||
|
||||
// Parse X-Spam-Report header for detailed test results
|
||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||
result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
||||
result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
|
||||
a.parseSpamReport(reportHeader, result)
|
||||
}
|
||||
|
||||
// Parse X-Spam-Checker-Version
|
||||
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
||||
result.Version = api.PtrTo(strings.TrimSpace(versionHeader))
|
||||
result.Version = strings.TrimSpace(versionHeader)
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
@ -82,7 +100,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
|||
|
||||
// parseSpamStatus parses the X-Spam-Status header
|
||||
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
||||
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) {
|
||||
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) {
|
||||
// Check if spam (first word)
|
||||
parts := strings.SplitN(header, ",", 2)
|
||||
if len(parts) > 0 {
|
||||
|
|
@ -94,7 +112,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
|
|||
scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`)
|
||||
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 {
|
||||
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
result.Score = score
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,19 +120,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
|
|||
requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`)
|
||||
if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 {
|
||||
if required, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||
result.RequiredScore = float32(required)
|
||||
result.RequiredScore = required
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tests
|
||||
testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`)
|
||||
testsRe := regexp.MustCompile(`tests=([^\s]+)`)
|
||||
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
|
||||
testsStr := matches[1]
|
||||
// Tests can be comma or space separated
|
||||
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
|
||||
return r == ',' || r == ' '
|
||||
})
|
||||
result.Tests = &tests
|
||||
result.Tests = tests
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,20 +140,17 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
|
|||
// Format varies, but typically:
|
||||
// * 1.5 TEST_NAME Description of test
|
||||
// * 0.0 TEST_NAME2 Description
|
||||
// Multiline descriptions continue on lines starting with * but without score:
|
||||
// * 0.0 TEST_NAME Description line 1
|
||||
// * continuation line 2
|
||||
// * continuation line 3
|
||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
|
||||
// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
|
||||
// We split on '*' to separate individual tests.
|
||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
|
||||
// The report header has been joined by mail.Header.Get(), so we split on '*'
|
||||
// Each segment starting with '*' is either a test line or continuation
|
||||
segments := strings.Split(report, "*")
|
||||
|
||||
// Regex to match test lines: score TEST_NAME Description
|
||||
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
|
||||
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
|
||||
|
||||
var currentTestName string
|
||||
var currentDescription strings.Builder
|
||||
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
|
|
@ -145,76 +160,186 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
|
|||
// Try to match as a test line
|
||||
matches := testRe.FindStringSubmatch(segment)
|
||||
if len(matches) > 3 {
|
||||
// Save previous test if exists
|
||||
if currentTestName != "" {
|
||||
description := strings.TrimSpace(currentDescription.String())
|
||||
detail := api.SpamTestDetail{
|
||||
Name: currentTestName,
|
||||
Score: result.TestDetails[currentTestName].Score,
|
||||
Description: &description,
|
||||
}
|
||||
result.TestDetails[currentTestName] = detail
|
||||
}
|
||||
|
||||
// Start new test
|
||||
testName := matches[2]
|
||||
score, _ := strconv.ParseFloat(matches[1], 64)
|
||||
description := strings.TrimSpace(matches[3])
|
||||
|
||||
currentTestName = testName
|
||||
currentDescription.Reset()
|
||||
currentDescription.WriteString(description)
|
||||
|
||||
// Initialize with score
|
||||
result.TestDetails[testName] = api.SpamTestDetail{
|
||||
Name: testName,
|
||||
Score: float32(score),
|
||||
detail := SpamTestDetail{
|
||||
Name: testName,
|
||||
Score: score,
|
||||
Description: description,
|
||||
}
|
||||
} else if currentTestName != "" {
|
||||
// This is a continuation line for the current test
|
||||
// Add a space before appending to ensure proper word separation
|
||||
if currentDescription.Len() > 0 {
|
||||
currentDescription.WriteString(" ")
|
||||
}
|
||||
currentDescription.WriteString(segment)
|
||||
result.TestDetails[testName] = detail
|
||||
}
|
||||
}
|
||||
|
||||
// Save the last test if exists
|
||||
if currentTestName != "" {
|
||||
description := strings.TrimSpace(currentDescription.String())
|
||||
detail := api.SpamTestDetail{
|
||||
Name: currentTestName,
|
||||
Score: result.TestDetails[currentTestName].Score,
|
||||
Description: &description,
|
||||
}
|
||||
result.TestDetails[currentTestName] = detail
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
||||
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
|
||||
// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points)
|
||||
// Scoring:
|
||||
// - Score <= 0: 2 points (excellent)
|
||||
// - Score < required: 1.5 points (good)
|
||||
// - Score slightly above required (< 2x): 1 point (borderline)
|
||||
// - Score moderately high (< 3x required): 0.5 points (poor)
|
||||
// - Score very high: 0 points (spam)
|
||||
func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 {
|
||||
if result == nil {
|
||||
return 100, "" // No spam scan results, assume good
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// SpamAssassin score typically ranges from -10 to +20
|
||||
// Score < 0 is very likely ham (good)
|
||||
// Score 0-5 is threshold range (configurable, usually 5.0)
|
||||
// Score > 5 is likely spam
|
||||
|
||||
score := result.Score
|
||||
|
||||
// Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
|
||||
if score < 0 {
|
||||
return 100, "A+" // Perfect score for ham
|
||||
} else if score == 0 {
|
||||
return 100, "A" // Perfect score for ham
|
||||
} else if score >= result.RequiredScore {
|
||||
return 0, "F" // Failed spam test
|
||||
} else {
|
||||
// Linear scale between 0 and required threshold
|
||||
percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore))))
|
||||
return percentage, ScoreToGrade(percentage - 5)
|
||||
required := result.RequiredScore
|
||||
if required == 0 {
|
||||
required = 5.0 // Default SpamAssassin threshold
|
||||
}
|
||||
|
||||
// Calculate deliverability score
|
||||
if score <= 0 {
|
||||
return 2.0
|
||||
} else if score < required {
|
||||
// Linear scaling from 1.5 to 2.0 based on how negative/low the score is
|
||||
ratio := score / required
|
||||
return 1.5 + (0.5 * (1.0 - float32(ratio)))
|
||||
} else if score < required*2 {
|
||||
// Slightly above threshold
|
||||
return 1.0
|
||||
} else if score < required*3 {
|
||||
// Moderately high
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// Very high spam score
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
|
||||
func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if result == nil {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Spam,
|
||||
Name: "SpamAssassin Analysis",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No SpamAssassin headers found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
|
||||
})
|
||||
return checks
|
||||
}
|
||||
|
||||
// Main spam score check
|
||||
mainCheck := a.generateMainSpamCheck(result)
|
||||
checks = append(checks, mainCheck)
|
||||
|
||||
// Add checks for significant spam tests (score > 1.0 or < -1.0)
|
||||
for _, test := range result.Tests {
|
||||
if detail, ok := result.TestDetails[test]; ok {
|
||||
if detail.Score > 1.0 || detail.Score < -1.0 {
|
||||
check := a.generateTestCheck(detail)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateMainSpamCheck creates the main spam score check
|
||||
func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Spam,
|
||||
Name: "SpamAssassin Score",
|
||||
}
|
||||
|
||||
score := result.Score
|
||||
required := result.RequiredScore
|
||||
if required == 0 {
|
||||
required = 5.0
|
||||
}
|
||||
|
||||
delivScore := a.GetSpamAssassinScore(result)
|
||||
check.Score = delivScore
|
||||
|
||||
// Determine status and message based on score
|
||||
if score <= 0 {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
|
||||
} else if score < required {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your email passes spam filters")
|
||||
} else if score < required*1.5 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
|
||||
} else if score < required*2 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
|
||||
} else {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
|
||||
}
|
||||
|
||||
// Add details
|
||||
if len(result.Tests) > 0 {
|
||||
details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
|
||||
if len(result.Tests) > 5 {
|
||||
details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
|
||||
}
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateTestCheck creates a check for a specific spam test
|
||||
func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Spam,
|
||||
Name: fmt.Sprintf("Spam Test: %s", detail.Name),
|
||||
}
|
||||
|
||||
if detail.Score > 0 {
|
||||
// Negative indicator (increases spam score)
|
||||
if detail.Score > 2.0 {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
} else {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
}
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
|
||||
advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
|
||||
check.Advice = &advice
|
||||
} else {
|
||||
// Positive indicator (decreases spam score)
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
|
||||
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
|
||||
check.Advice = &advice
|
||||
}
|
||||
|
||||
check.Details = &detail.Description
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// min returns the minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) {
|
|||
name string
|
||||
header string
|
||||
expectedIsSpam bool
|
||||
expectedScore float32
|
||||
expectedReq float32
|
||||
expectedScore float64
|
||||
expectedReq float64
|
||||
expectedTests []string
|
||||
}{
|
||||
{
|
||||
|
|
@ -77,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
result := &SpamAssassinResult{
|
||||
TestDetails: make(map[string]SpamTestDetail),
|
||||
}
|
||||
analyzer.parseSpamStatus(tt.header, result)
|
||||
|
||||
|
|
@ -91,12 +91,8 @@ func TestParseSpamStatus(t *testing.T) {
|
|||
if result.RequiredScore != tt.expectedReq {
|
||||
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq)
|
||||
}
|
||||
if len(tt.expectedTests) > 0 {
|
||||
if result.Tests == nil {
|
||||
t.Errorf("Tests = nil, want %v", tt.expectedTests)
|
||||
} else if !stringSliceEqual(*result.Tests, tt.expectedTests) {
|
||||
t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests)
|
||||
}
|
||||
if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) {
|
||||
t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -115,27 +111,27 @@ func TestParseSpamReport(t *testing.T) {
|
|||
`
|
||||
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
result := &SpamAssassinResult{
|
||||
TestDetails: make(map[string]SpamTestDetail),
|
||||
}
|
||||
|
||||
analyzer.parseSpamReport(report, result)
|
||||
|
||||
expectedTests := map[string]api.SpamTestDetail{
|
||||
expectedTests := map[string]SpamTestDetail{
|
||||
"BAYES_99": {
|
||||
Name: "BAYES_99",
|
||||
Score: 5.0,
|
||||
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
|
||||
Description: "Bayes spam probability is 99 to 100%",
|
||||
},
|
||||
"SPOOFED_SENDER": {
|
||||
Name: "SPOOFED_SENDER",
|
||||
Score: 3.5,
|
||||
Description: api.PtrTo("From address doesn't match envelope sender"),
|
||||
Description: "From address doesn't match envelope sender",
|
||||
},
|
||||
"ALL_TRUSTED": {
|
||||
Name: "ALL_TRUSTED",
|
||||
Score: -1.0,
|
||||
Description: api.PtrTo("All mail servers are trusted"),
|
||||
Description: "All mail servers are trusted",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -148,8 +144,8 @@ func TestParseSpamReport(t *testing.T) {
|
|||
if detail.Score != expected.Score {
|
||||
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score)
|
||||
}
|
||||
if *detail.Description != *expected.Description {
|
||||
t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description)
|
||||
if detail.Description != expected.Description {
|
||||
t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,63 +153,56 @@ func TestParseSpamReport(t *testing.T) {
|
|||
func TestGetSpamAssassinScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *api.SpamAssassinResult
|
||||
expectedScore int
|
||||
minScore int
|
||||
maxScore int
|
||||
result *SpamAssassinResult
|
||||
expectedScore float32
|
||||
minScore float32
|
||||
maxScore float32
|
||||
}{
|
||||
{
|
||||
name: "Nil result",
|
||||
result: nil,
|
||||
expectedScore: 100,
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "Excellent score (negative)",
|
||||
result: &api.SpamAssassinResult{
|
||||
result: &SpamAssassinResult{
|
||||
Score: -2.5,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "Good score (below threshold)",
|
||||
result: &api.SpamAssassinResult{
|
||||
result: &SpamAssassinResult{
|
||||
Score: 2.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60
|
||||
minScore: 1.5,
|
||||
maxScore: 2.0,
|
||||
},
|
||||
{
|
||||
name: "Score at threshold",
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: 5.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
},
|
||||
{
|
||||
name: "Above threshold (spam)",
|
||||
result: &api.SpamAssassinResult{
|
||||
name: "Borderline (just above threshold)",
|
||||
result: &SpamAssassinResult{
|
||||
Score: 6.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
expectedScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "High spam score",
|
||||
result: &api.SpamAssassinResult{
|
||||
result: &SpamAssassinResult{
|
||||
Score: 12.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
expectedScore: 0.5,
|
||||
},
|
||||
{
|
||||
name: "Very high spam score",
|
||||
result: &api.SpamAssassinResult{
|
||||
result: &SpamAssassinResult{
|
||||
Score: 20.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
expectedScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -221,7 +210,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := analyzer.CalculateSpamAssassinScore(tt.result)
|
||||
score := analyzer.GetSpamAssassinScore(tt.result)
|
||||
|
||||
if tt.minScore > 0 || tt.maxScore > 0 {
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
|
|
@ -241,7 +230,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
|
|||
name string
|
||||
headers map[string]string
|
||||
expectedIsSpam bool
|
||||
expectedScore float32
|
||||
expectedScore float64
|
||||
expectedHasDetails bool
|
||||
}{
|
||||
{
|
||||
|
|
@ -307,6 +296,86 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateSpamAssassinChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *SpamAssassinResult
|
||||
expectedStatus api.CheckStatus
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil result",
|
||||
result: nil,
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
minChecks: 1,
|
||||
},
|
||||
{
|
||||
name: "Clean email",
|
||||
result: &SpamAssassinResult{
|
||||
IsSpam: false,
|
||||
Score: -0.5,
|
||||
RequiredScore: 5.0,
|
||||
Tests: []string{"ALL_TRUSTED"},
|
||||
TestDetails: map[string]SpamTestDetail{
|
||||
"ALL_TRUSTED": {
|
||||
Name: "ALL_TRUSTED",
|
||||
Score: -1.5,
|
||||
Description: "All mail servers are trusted",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
minChecks: 2, // Main check + one test detail
|
||||
},
|
||||
{
|
||||
name: "Spam email",
|
||||
result: &SpamAssassinResult{
|
||||
IsSpam: true,
|
||||
Score: 15.0,
|
||||
RequiredScore: 5.0,
|
||||
Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
|
||||
TestDetails: map[string]SpamTestDetail{
|
||||
"BAYES_99": {
|
||||
Name: "BAYES_99",
|
||||
Score: 5.0,
|
||||
Description: "Bayes spam probability is 99 to 100%",
|
||||
},
|
||||
"SPOOFED_SENDER": {
|
||||
Name: "SPOOFED_SENDER",
|
||||
Score: 3.5,
|
||||
Description: "From address doesn't match envelope sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
minChecks: 3, // Main check + two significant tests
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateSpamAssassinChecks(tt.result)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Check main check (first one)
|
||||
if len(checks) > 0 {
|
||||
mainCheck := checks[0]
|
||||
if mainCheck.Status != tt.expectedStatus {
|
||||
t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
|
||||
}
|
||||
if mainCheck.Category != api.Spam {
|
||||
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
email := &EmailMessage{
|
||||
|
|
@ -320,6 +389,98 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateMainSpamCheck(t *testing.T) {
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
score float64
|
||||
required float64
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{"Excellent", -1.0, 5.0, api.CheckStatusPass},
|
||||
{"Good", 2.0, 5.0, api.CheckStatusPass},
|
||||
{"Borderline", 6.0, 5.0, api.CheckStatusWarn},
|
||||
{"High", 8.0, 5.0, api.CheckStatusWarn},
|
||||
{"Very High", 15.0, 5.0, api.CheckStatusFail},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &SpamAssassinResult{
|
||||
Score: tt.score,
|
||||
RequiredScore: tt.required,
|
||||
}
|
||||
|
||||
check := analyzer.generateMainSpamCheck(result)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Spam {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
|
||||
}
|
||||
if !strings.Contains(check.Message, "spam score") {
|
||||
t.Error("Message should contain 'spam score'")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTestCheck(t *testing.T) {
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
detail SpamTestDetail
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "High penalty test",
|
||||
detail: SpamTestDetail{
|
||||
Name: "BAYES_99",
|
||||
Score: 5.0,
|
||||
Description: "Bayes spam probability is 99 to 100%",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
{
|
||||
name: "Medium penalty test",
|
||||
detail: SpamTestDetail{
|
||||
Name: "HTML_MESSAGE",
|
||||
Score: 1.5,
|
||||
Description: "Contains HTML",
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
{
|
||||
name: "Positive test",
|
||||
detail: SpamTestDetail{
|
||||
Name: "ALL_TRUSTED",
|
||||
Score: -2.0,
|
||||
Description: "All mail servers are trusted",
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateTestCheck(tt.detail)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Spam {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
|
||||
}
|
||||
if !strings.Contains(check.Name, tt.detail.Name) {
|
||||
t.Errorf("Check name should contain test name %s", tt.detail.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
|
||||
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
|
||||
|
|
@ -381,26 +542,24 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
|
|||
}
|
||||
|
||||
// Validate score (should be -0.1)
|
||||
var expectedScore float32 = -0.1
|
||||
expectedScore := -0.1
|
||||
if result.Score != expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
|
||||
}
|
||||
|
||||
// Validate required score (should be 5.0)
|
||||
var expectedRequired float32 = 5.0
|
||||
expectedRequired := 5.0
|
||||
if result.RequiredScore != expectedRequired {
|
||||
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
|
||||
}
|
||||
|
||||
// Validate version
|
||||
if result.Version == nil {
|
||||
t.Errorf("Version should contain 'SpamAssassin', got: nil")
|
||||
} else if !strings.Contains(*result.Version, "SpamAssassin") {
|
||||
t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version)
|
||||
if !strings.Contains(result.Version, "SpamAssassin") {
|
||||
t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version)
|
||||
}
|
||||
|
||||
// Validate that tests were extracted
|
||||
if len(*result.Tests) == 0 {
|
||||
if len(result.Tests) == 0 {
|
||||
t.Error("Expected tests to be extracted, got none")
|
||||
}
|
||||
|
||||
|
|
@ -413,7 +572,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
|
|||
"SPF_HELO_NONE": true,
|
||||
}
|
||||
|
||||
for _, testName := range *result.Tests {
|
||||
for _, testName := range result.Tests {
|
||||
if expectedTests[testName] {
|
||||
t.Logf("Found expected test: %s", testName)
|
||||
}
|
||||
|
|
@ -427,11 +586,11 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
|
|||
// Log what we actually got for debugging
|
||||
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
|
||||
for name, detail := range result.TestDetails {
|
||||
t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description)
|
||||
t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
|
||||
}
|
||||
|
||||
// Define expected test details with their scores
|
||||
expectedTestDetails := map[string]float32{
|
||||
expectedTestDetails := map[string]float64{
|
||||
"SPF_PASS": -0.0,
|
||||
"SPF_HELO_NONE": 0.0,
|
||||
"DKIM_VALID": -0.1,
|
||||
|
|
@ -452,15 +611,43 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
|
|||
if detail.Score != expectedScore {
|
||||
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
|
||||
}
|
||||
if detail.Description == nil || *detail.Description == "" {
|
||||
if detail.Description == "" {
|
||||
t.Errorf("Test %s should have a description", testName)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetSpamAssassinScore
|
||||
score, _ := analyzer.CalculateSpamAssassinScore(result)
|
||||
if score != 100 {
|
||||
t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
|
||||
score := analyzer.GetSpamAssassinScore(result)
|
||||
if score != 2.0 {
|
||||
t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
|
||||
}
|
||||
|
||||
// Test GenerateSpamAssassinChecks
|
||||
checks := analyzer.GenerateSpamAssassinChecks(result)
|
||||
if len(checks) < 1 {
|
||||
t.Fatal("Expected at least 1 check, got none")
|
||||
}
|
||||
|
||||
// Main check should be PASS with excellent score
|
||||
mainCheck := checks[0]
|
||||
if mainCheck.Status != api.CheckStatusPass {
|
||||
t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
|
||||
}
|
||||
if mainCheck.Category != api.Spam {
|
||||
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
|
||||
}
|
||||
if !strings.Contains(mainCheck.Message, "spam score") {
|
||||
t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
|
||||
}
|
||||
if mainCheck.Score != 2.0 {
|
||||
t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
|
||||
}
|
||||
|
||||
// Log all checks for debugging
|
||||
t.Logf("Generated %d checks:", len(checks))
|
||||
for i, check := range checks {
|
||||
t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
|
||||
i+1, check.Name, check.Message, check.Score, check.Status)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1316
web/package-lock.json
generated
1316
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,17 +16,17 @@
|
|||
"generate:api": "openapi-ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@hey-api/openapi-ts": "0.86.10",
|
||||
"@hey-api/openapi-ts": "0.85.2",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"eslint": "^9.38.0",
|
||||
"@types/node": "^22",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^17.0.0",
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.39.5",
|
||||
|
|
|
|||
|
|
@ -23,10 +23,9 @@ package web
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -42,34 +41,12 @@ import (
|
|||
|
||||
var (
|
||||
indexTpl *template.Template
|
||||
CustomBodyHTML = ""
|
||||
CustomHeadHTML = ""
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before </head>")
|
||||
flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before </body>")
|
||||
}
|
||||
|
||||
func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||
appConfig := map[string]interface{}{}
|
||||
|
||||
if cfg.ReportRetention > 0 {
|
||||
appConfig["report_retention"] = cfg.ReportRetention
|
||||
}
|
||||
|
||||
if cfg.SurveyURL.Host != "" {
|
||||
appConfig["survey_url"] = cfg.SurveyURL.String()
|
||||
}
|
||||
|
||||
if len(cfg.Analysis.RBLs) > 0 {
|
||||
appConfig["rbls"] = cfg.Analysis.RBLs
|
||||
}
|
||||
|
||||
if cfg.CustomLogoURL != "" {
|
||||
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
||||
}
|
||||
|
||||
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
||||
log.Println("Unable to generate JSON config to inject in web application")
|
||||
} else {
|
||||
|
|
@ -89,12 +66,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
|||
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||
|
||||
router.GET("/", serveOrReverse("/", cfg))
|
||||
router.GET("/blacklist/", serveOrReverse("/", cfg))
|
||||
router.GET("/blacklist/:ip", serveOrReverse("/", cfg))
|
||||
router.GET("/domain/", serveOrReverse("/", cfg))
|
||||
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
||||
router.GET("/test/", serveOrReverse("/", cfg))
|
||||
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
||||
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||
router.GET("/img/*path", serveOrReverse("", cfg))
|
||||
|
||||
|
|
@ -114,7 +85,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
|||
if u, err := url.Parse(cfg.DevProxy); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
if forced_url != "" && forced_url != "/" {
|
||||
if forced_url != "" {
|
||||
u.Path = path.Join(u.Path, forced_url)
|
||||
} else {
|
||||
u.Path = path.Join(u.Path, c.Request.URL.Path)
|
||||
|
|
@ -143,16 +114,14 @@ 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(string(v), "</head>", "{{ .Head }}</head>", 1)
|
||||
|
||||
indexTpl = template.Must(template.New("index.html").Parse(v2))
|
||||
|
||||
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
|
||||
"Head": CustomHeadHTML,
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
}
|
||||
|
|
@ -170,18 +139,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
|||
if indexTpl == nil {
|
||||
// Create template from file
|
||||
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(string(v), "</head>", "{{ .Head }}</head>", 1)
|
||||
|
||||
indexTpl = template.Must(template.New("index.html").Parse(v2))
|
||||
}
|
||||
|
||||
// Serve template
|
||||
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
|
||||
"Head": CustomHeadHTML,
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
:root {
|
||||
--bs-primary: #1cb487;
|
||||
--bs-primary-rgb: 28, 180, 135;
|
||||
--bs-link-color-rgb: 28, 180, 135;
|
||||
--bs-link-hover-color-rgb: 17, 112, 84;
|
||||
--bs-tertiary-bg: #e7e8e8;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -11,10 +8,6 @@ body {
|
|||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.bg-tertiary {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
|
@ -81,21 +74,14 @@ body {
|
|||
|
||||
/* Custom card styling */
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:not(.fade-in .card) {
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fade-in .card:not(.card .card) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card:hover:not(.fade-in .card) {
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,38 +3,9 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="happyDeliver - Test Your Email Deliverability" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
|
||||
/>
|
||||
<meta property="og:image" content="/img/og.webp" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="happyDeliver - Test Your Email Deliverability" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
|
||||
/>
|
||||
<meta name="twitter:image" content="/img/og.webp" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<script>
|
||||
// Apply theme before render to prevent flash
|
||||
(function () {
|
||||
const stored = localStorage.getItem("theme");
|
||||
const theme =
|
||||
stored ||
|
||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
})();
|
||||
</script>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="256"
|
||||
height="256"
|
||||
viewBox="0 0 67.733332 67.733335"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="favicon.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2">
|
||||
<marker
|
||||
orient="auto"
|
||||
refY="0.0"
|
||||
refX="0.0"
|
||||
id="Arrow1Lstart"
|
||||
style="overflow:visible">
|
||||
<path
|
||||
id="path1110"
|
||||
d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z "
|
||||
style="fill-rule:evenodd;stroke:#000000;stroke-width:1.0pt"
|
||||
transform="scale(0.8) translate(12.5,0)" />
|
||||
</marker>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
showborder="true"
|
||||
borderlayer="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(0,-229.26665)">
|
||||
<path
|
||||
style="fill:none;stroke:#d7b3e6;stroke-width:3.0463;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 4.744943,232.9437 62.17147,232.53602 35.338987,253.12317 Z"
|
||||
id="path1" />
|
||||
<rect
|
||||
style="fill:#1cb487;fill-opacity:0;stroke:#c38dd9;stroke-width:6.17022181;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect819"
|
||||
width="61.563114"
|
||||
height="61.56311"
|
||||
x="232.35176"
|
||||
y="-64.648224"
|
||||
ry="2.1808801"
|
||||
rx="2.147301"
|
||||
transform="rotate(90)" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:113.242px;line-height:125%;font-family:'Fortheenas_01 Bold';-inkscape-font-specification:'Fortheenas_01 Bold, ';letter-spacing:0px;word-spacing:0px;fill:#871cb4;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="-5.1976156"
|
||||
y="308.21759"
|
||||
id="text817"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan815"
|
||||
x="-5.1976156"
|
||||
y="308.21759"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:107.597px;line-height:100%;font-family:'Fortheenas_01 Bold';-inkscape-font-specification:'Fortheenas_01 Bold, ';writing-mode:lr-tb;direction:ltr;fill:#871cb4;fill-opacity:1;stroke-width:0.264583px">h</tspan></text>
|
||||
<path
|
||||
style="fill:none;fill-rule:evenodd;stroke:#871cb4;stroke-width:12.70902443;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 5.0595093,228.81348 -0.02912,7.90683"
|
||||
id="path1395"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect1589"
|
||||
width="119.36257"
|
||||
height="43.404568"
|
||||
x="-36.662537"
|
||||
y="297.12582"
|
||||
rx="0.94925886"
|
||||
ry="0.91267759" />
|
||||
<path
|
||||
d="m 57.00432,267.54566 a 2.7691676,2.7691676 0 0 1 3.950621,3.8768 l -14.731868,18.42418 a 2.7691676,2.7691676 0 0 1 -3.987601,0.0697 l -9.769659,-9.76952 a 2.7691676,2.7691676 0 1 1 3.913781,-3.91379 l 7.731412,7.72779 12.823121,-16.33807 z"
|
||||
id="path1-3"
|
||||
style="fill:#67ccaf;fill-opacity:0;stroke-width:3.69222" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
|
@ -1,539 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { AuthenticationResults, DnsResults } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
authentication: AuthenticationResults;
|
||||
authenticationGrade?: string;
|
||||
authenticationScore?: number;
|
||||
dnsResults?: DnsResults;
|
||||
}
|
||||
|
||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||
|
||||
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
||||
switch (result) {
|
||||
case "pass":
|
||||
case "domain_pass":
|
||||
case "orgdomain_pass":
|
||||
return "text-success";
|
||||
case "permerror":
|
||||
case "error":
|
||||
case "fail":
|
||||
case "missing":
|
||||
case "invalid":
|
||||
case "null":
|
||||
case "null_smtp":
|
||||
case "null_header":
|
||||
return "text-danger";
|
||||
case "softfail":
|
||||
case "neutral":
|
||||
return "text-warning";
|
||||
case "declined":
|
||||
return "text-info";
|
||||
case "none":
|
||||
return noneIsFail ? "text-danger" : "text-muted";
|
||||
default:
|
||||
return "text-muted";
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthResultIcon(result: string, noneIsFail: boolean): string {
|
||||
switch (result) {
|
||||
case "pass":
|
||||
case "domain_pass":
|
||||
case "orgdomain_pass":
|
||||
return "bi-check-circle-fill";
|
||||
case "fail":
|
||||
return "bi-x-circle-fill";
|
||||
case "softfail":
|
||||
case "neutral":
|
||||
case "invalid":
|
||||
case "null":
|
||||
case "permerror":
|
||||
case "error":
|
||||
case "null_smtp":
|
||||
case "null_header":
|
||||
return "bi-exclamation-circle-fill";
|
||||
case "missing":
|
||||
return "bi-dash-circle-fill";
|
||||
case "declined":
|
||||
return "bi-dash-circle";
|
||||
case "none":
|
||||
return noneIsFail ? "bi-x-circle-fill" : "bi-question-circle";
|
||||
default:
|
||||
return "bi-question-circle";
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthResultText(result: string): string {
|
||||
switch (result) {
|
||||
case "missing":
|
||||
return "Not configured";
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="authentication-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-shield-check me-2"></i>
|
||||
Authentication
|
||||
</span>
|
||||
<span>
|
||||
{#if authenticationScore !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(authenticationScore)}">
|
||||
{authenticationScore}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if authenticationGrade !== undefined}
|
||||
<GradeDisplay grade={authenticationGrade} size="small" />
|
||||
{/if}
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
<div class="list-group-item" id="authentication-iprev">
|
||||
<div class="d-flex align-items-start">
|
||||
<i
|
||||
class="bi {getAuthResultIcon(
|
||||
authentication.iprev.result,
|
||||
true,
|
||||
)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"
|
||||
></i>
|
||||
<div>
|
||||
<strong>IP Reverse DNS</strong>
|
||||
<span
|
||||
class="text-uppercase ms-2 {getAuthResultClass(
|
||||
authentication.iprev.result,
|
||||
true,
|
||||
)}"
|
||||
>
|
||||
{authentication.iprev.result}
|
||||
</span>
|
||||
{#if authentication.iprev.ip}
|
||||
<div class="small">
|
||||
<strong>IP Address:</strong>
|
||||
<span class="text-muted">{authentication.iprev.ip}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.hostname}
|
||||
<div class="small">
|
||||
<strong>Hostname:</strong>
|
||||
<span class="text-muted">{authentication.iprev.hostname}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.details}
|
||||
<pre
|
||||
class="p-2 mb-0 {$theme === 'light'
|
||||
? 'bg-light'
|
||||
: 'bg-secondary'} text-muted small"
|
||||
style="white-space: pre-wrap">{authentication.iprev.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { BimiRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
bimiRecord?: BimiRecord;
|
||||
}
|
||||
|
||||
let { bimiRecord }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if bimiRecord}
|
||||
<div class="card mb-4" id="dns-bimi">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-0">
|
||||
<i
|
||||
class="bi"
|
||||
class:bi-check-circle-fill={bimiRecord.valid}
|
||||
class:text-success={bimiRecord.valid}
|
||||
class:bi-x-circle-fill={!bimiRecord.valid}
|
||||
class:text-danger={!bimiRecord.valid}
|
||||
></i>
|
||||
Brand Indicators for Message Identification
|
||||
</h5>
|
||||
<span class="badge bg-secondary">BIMI</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small text-muted mb-2">
|
||||
BIMI allows your brand logo to be displayed next to your emails in supported mail
|
||||
clients. Requires strong DMARC enforcement (quarantine or reject policy) and
|
||||
optionally a Verified Mark Certificate (VMC).
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Selector:</strong> <code>{bimiRecord.selector}</code>
|
||||
<strong class="ms-3">Domain:</strong> <code>{bimiRecord.domain}</code>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<strong>Status:</strong>
|
||||
{#if bimiRecord.valid}
|
||||
<span class="badge bg-success">Valid</span>
|
||||
{:else}
|
||||
<span class="badge bg-danger">Invalid</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bimiRecord.logo_url}
|
||||
<div class="mb-2">
|
||||
<strong>Logo URL:</strong>
|
||||
<a href={bimiRecord.logo_url} target="_blank" rel="noopener noreferrer"
|
||||
>{bimiRecord.logo_url}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bimiRecord.vmc_url}
|
||||
<div class="mb-2">
|
||||
<strong>VMC URL:</strong>
|
||||
<a href={bimiRecord.vmc_url} target="_blank" rel="noopener noreferrer"
|
||||
>{bimiRecord.vmc_url}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bimiRecord.record}
|
||||
<div class="mb-2">
|
||||
<strong>Record:</strong><br />
|
||||
<code class="d-block mt-1 text-break">{bimiRecord.record}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bimiRecord.error}
|
||||
<div class="text-danger">
|
||||
<strong>Error:</strong>
|
||||
{bimiRecord.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
blacklists: Record<string, BlacklistCheck[]>;
|
||||
blacklistGrade?: string;
|
||||
blacklistScore?: number;
|
||||
}
|
||||
|
||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="rbl-details">
|
||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-shield-exclamation me-2"></i>
|
||||
Blacklist Checks
|
||||
</span>
|
||||
<span>
|
||||
{#if blacklistScore !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(blacklistScore)}">
|
||||
{blacklistScore}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if blacklistGrade !== undefined}
|
||||
<GradeDisplay grade={blacklistGrade} size="small" />
|
||||
{/if}
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
||||
{#each Object.entries(blacklists) as [ip, checks]}
|
||||
<div class="col mb-3">
|
||||
<h5 class="text-muted">
|
||||
<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 {check.listed
|
||||
? 'bg-danger'
|
||||
: check.error
|
||||
? 'bg-dark'
|
||||
: 'bg-success'}"
|
||||
>
|
||||
{check.error
|
||||
? "Error"
|
||||
: check.listed
|
||||
? "Listed"
|
||||
: "Clean"}
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{check.rbl}</code></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
74
web/src/lib/components/CheckCard.svelte
Normal file
74
web/src/lib/components/CheckCard.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts">
|
||||
import type { Check } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
check: Check;
|
||||
}
|
||||
|
||||
let { check }: Props = $props();
|
||||
|
||||
function getCheckIcon(status: string): string {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "bi-check-circle-fill text-success";
|
||||
case "fail":
|
||||
return "bi-x-circle-fill text-danger";
|
||||
case "warn":
|
||||
return "bi-exclamation-triangle-fill text-warning";
|
||||
case "info":
|
||||
return "bi-info-circle-fill text-info";
|
||||
default:
|
||||
return "bi-question-circle-fill text-secondary";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="fs-4">
|
||||
<i class={getCheckIcon(check.status)}></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-1">{check.name}</h5>
|
||||
<span class="badge bg-secondary text-capitalize">{check.category}</span>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark">{check.score.toFixed(1)} pts</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 mb-2">{check.message}</p>
|
||||
|
||||
{#if check.advice}
|
||||
<div class="alert alert-light border mb-2" role="alert">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
<strong>Recommendation:</strong>
|
||||
{check.advice}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if check.details}
|
||||
<details class="small text-muted">
|
||||
<summary class="cursor-pointer">Technical Details</summary>
|
||||
<pre class="mt-2 mb-0 small bg-light p-2 rounded">{check.details}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details summary {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
details summary:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { ContentAnalysis } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
contentAnalysis: ContentAnalysis;
|
||||
contentGrade?: string;
|
||||
contentScore?: number;
|
||||
}
|
||||
|
||||
let { contentAnalysis, contentGrade, contentScore }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="content-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-file-text me-2"></i>
|
||||
Content Analysis
|
||||
</span>
|
||||
<span>
|
||||
{#if contentScore !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(contentScore)}">
|
||||
{contentScore}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if contentGrade !== undefined}
|
||||
<GradeDisplay grade={contentGrade} size="small" />
|
||||
{/if}
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i
|
||||
class="bi {contentAnalysis.has_html
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-muted'} me-2"
|
||||
></i>
|
||||
<span>HTML Part</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i
|
||||
class="bi {contentAnalysis.has_plaintext
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-muted'} me-2"
|
||||
></i>
|
||||
<span>Plaintext Part</span>
|
||||
</div>
|
||||
{#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i
|
||||
class="bi {contentAnalysis.has_unsubscribe_link
|
||||
? 'bi-check-circle text-success'
|
||||
: 'bi-x-circle text-warning'} me-2"
|
||||
></i>
|
||||
<span>Unsubscribe Link</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{#if contentAnalysis.text_to_image_ratio !== undefined}
|
||||
<div class="mb-2">
|
||||
<strong>Text to Image Ratio:</strong>
|
||||
<span class="ms-2">{contentAnalysis.text_to_image_ratio.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contentAnalysis.unsubscribe_methods && contentAnalysis.unsubscribe_methods.length > 0}
|
||||
<div class="mb-2">
|
||||
<strong>Unsubscribe Methods:</strong>
|
||||
<div class="mt-1">
|
||||
{#each contentAnalysis.unsubscribe_methods as method}
|
||||
<span class="badge bg-info me-1">{method}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if contentAnalysis.html_issues && contentAnalysis.html_issues.length > 0}
|
||||
<div class="mt-3">
|
||||
<h5>Content Issues</h5>
|
||||
{#each contentAnalysis.html_issues as issue}
|
||||
<div
|
||||
class="alert alert-{issue.severity === 'critical' ||
|
||||
issue.severity === 'high'
|
||||
? 'danger'
|
||||
: issue.severity === 'medium'
|
||||
? 'warning'
|
||||
: 'info'} py-2 px-3 mb-2"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{issue.type}</strong>
|
||||
<div class="small">{issue.message}</div>
|
||||
{#if issue.location}
|
||||
<div class="small text-muted">{issue.location}</div>
|
||||
{/if}
|
||||
{#if issue.advice}
|
||||
<div class="small mt-1">
|
||||
<i class="bi bi-lightbulb me-1"></i>
|
||||
{issue.advice}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="badge bg-secondary">{issue.severity}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contentAnalysis.links && contentAnalysis.links.length > 0}
|
||||
<div class="mt-3">
|
||||
<h5>Links ({contentAnalysis.links.length})</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th>HTTP Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each contentAnalysis.links as link}
|
||||
<tr>
|
||||
<td>
|
||||
<small class="text-break">{link.url}</small>
|
||||
{#if link.is_shortened}
|
||||
<span class="badge bg-warning ms-1">Shortened</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge {link.status === 'valid'
|
||||
? 'bg-success'
|
||||
: link.status === 'broken'
|
||||
? 'bg-danger'
|
||||
: 'bg-warning'}"
|
||||
>
|
||||
{link.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{link.http_code || "-"}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contentAnalysis.images && contentAnalysis.images.length > 0}
|
||||
<div class="mt-3">
|
||||
<h5>Images ({contentAnalysis.images.length})</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Alt Text</th>
|
||||
<th>Tracking</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each contentAnalysis.images as image}
|
||||
<tr>
|
||||
<td><small class="text-break">{image.src || "-"}</small></td>
|
||||
<td>
|
||||
{#if image.has_alt}
|
||||
<i class="bi bi-check-circle text-success me-1"></i>
|
||||
<small>{image.alt_text || "Present"}</small>
|
||||
{:else}
|
||||
<i class="bi bi-x-circle text-warning me-1"></i>
|
||||
<small class="text-muted">Missing</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if image.is_tracking_pixel}
|
||||
<span class="badge bg-info">Tracking Pixel</span>
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { DkimRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
dkimRecords?: DkimRecord[];
|
||||
}
|
||||
|
||||
let { dkimRecords }: Props = $props();
|
||||
|
||||
// Compute overall validity
|
||||
const dkimIsValid = $derived(dkimRecords?.reduce((acc, r) => acc && r.valid, true) ?? false);
|
||||
</script>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-0">
|
||||
<i
|
||||
class="bi"
|
||||
class:bi-check-circle-fill={dkimIsValid}
|
||||
class:text-success={dkimIsValid}
|
||||
class:bi-x-circle-fill={!dkimIsValid}
|
||||
class:text-danger={!dkimIsValid}
|
||||
></i>
|
||||
DomainKeys Identified Mail
|
||||
</h5>
|
||||
<span class="badge bg-secondary">DKIM</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small text-muted mb-0">
|
||||
DKIM cryptographically signs your emails, proving they haven't been tampered with in
|
||||
transit. Receiving servers verify this signature against your DNS records.
|
||||
</p>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{#if dkimRecords && dkimRecords.length > 0}
|
||||
{#each dkimRecords as dkim}
|
||||
<div class="list-group-item">
|
||||
<div class="mb-2">
|
||||
<strong>Selector:</strong> <code>{dkim.selector}</code>
|
||||
<strong class="ms-3">Domain:</strong> <code>{dkim.domain}</code>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<strong>Status:</strong>
|
||||
{#if dkim.valid}
|
||||
<span class="badge bg-success">Valid</span>
|
||||
{:else}
|
||||
<span class="badge bg-danger">Invalid</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dkim.record}
|
||||
<div class="mb-2">
|
||||
<strong>Record:</strong><br />
|
||||
<code class="d-block mt-1 text-break small text-truncate"
|
||||
>{dkim.record}</code
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if dkim.error}
|
||||
<div class="text-danger">
|
||||
<strong>Error:</strong>
|
||||
{dkim.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="list-group-item text-muted">
|
||||
<i class="bi bi-exclamation-octagon me-2"></i>
|
||||
No DKIM signatures found in this email. DKIM provides cryptographic authentication and
|
||||
helps avoid spoofing, thus improving deliverability.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { DmarcRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
dmarcRecord?: DmarcRecord;
|
||||
}
|
||||
|
||||
let { dmarcRecord }: Props = $props();
|
||||
|
||||
// Helper function to determine policy strength
|
||||
const policyStrength = (policy: string | undefined): number => {
|
||||
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
||||
return strength[policy || "none"] || 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if dmarcRecord}
|
||||
<div class="card mb-4" id="dns-dmarc">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-0">
|
||||
<i
|
||||
class="bi"
|
||||
class:bi-check-circle-fill={dmarcRecord.valid && dmarcRecord.policy != "none"}
|
||||
class:text-success={dmarcRecord.valid && dmarcRecord.policy != "none"}
|
||||
class:bi-arrow-up-circle-fill={dmarcRecord.valid &&
|
||||
dmarcRecord.policy == "none"}
|
||||
class:text-warning={dmarcRecord.valid && dmarcRecord.policy == "none"}
|
||||
class:bi-x-circle-fill={!dmarcRecord.valid}
|
||||
class:text-danger={!dmarcRecord.valid}
|
||||
></i>
|
||||
Domain-based Message Authentication
|
||||
</h5>
|
||||
<span class="badge bg-secondary">DMARC</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small text-muted mb-2">
|
||||
DMARC enforces domain alignment requirements (regardless of the policy). It builds
|
||||
on SPF and DKIM by telling receiving servers what to do with emails that fail
|
||||
authentication checks. It also enables reporting so you can monitor your email
|
||||
security.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-2">
|
||||
<strong>Status:</strong>
|
||||
{#if dmarcRecord.valid}
|
||||
<span class="badge bg-success">Valid</span>
|
||||
{:else}
|
||||
<span class="badge bg-danger">Invalid</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Policy -->
|
||||
{#if dmarcRecord.policy}
|
||||
<div class="mb-3">
|
||||
<strong>Policy:</strong>
|
||||
<span
|
||||
class="badge {dmarcRecord.policy === 'reject'
|
||||
? 'bg-success'
|
||||
: dmarcRecord.policy === 'quarantine'
|
||||
? 'bg-warning'
|
||||
: 'bg-secondary'}"
|
||||
>
|
||||
{dmarcRecord.policy}
|
||||
</span>
|
||||
{#if dmarcRecord.policy === "reject"}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
<strong>Maximum protection</strong> — emails failing DMARC checks are rejected.
|
||||
This provides the strongest defense against spoofing and phishing.
|
||||
</div>
|
||||
{:else if dmarcRecord.policy === "quarantine"}
|
||||
<div class="alert alert-info mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Good protection</strong> — emails failing DMARC checks are
|
||||
quarantined (sent to spam). This is a safe middle ground.<br />
|
||||
<i class="bi bi-arrow-up-circle me-1"></i>
|
||||
Once you've validated your configuration and ensured all legitimate mail
|
||||
passes, consider upgrading to <code>p=reject</code> for maximum protection.
|
||||
</div>
|
||||
{:else if dmarcRecord.policy === "none"}
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Monitoring only</strong> — emails failing DMARC are delivered
|
||||
normally. This is only recommended during initial setup.<br />
|
||||
<i class="bi bi-arrow-up-circle me-1"></i>
|
||||
After monitoring reports, upgrade to <code>p=quarantine</code> or
|
||||
<code>p=reject</code> to actively protect your domain.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-danger mt-2 mb-0 small">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
<strong>Unknown policy</strong> — the policy value is not recognized. Valid
|
||||
options are: none, quarantine, or reject.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Subdomain Policy -->
|
||||
{#if dmarcRecord.subdomain_policy}
|
||||
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
||||
{@const subStrength = policyStrength(dmarcRecord.subdomain_policy)}
|
||||
<div class="mb-3">
|
||||
<strong>Subdomain Policy:</strong>
|
||||
<span
|
||||
class="badge {dmarcRecord.subdomain_policy === 'reject'
|
||||
? 'bg-success'
|
||||
: dmarcRecord.subdomain_policy === 'quarantine'
|
||||
? 'bg-warning'
|
||||
: 'bg-secondary'}"
|
||||
>
|
||||
{dmarcRecord.subdomain_policy}
|
||||
</span>
|
||||
{#if subStrength >= mainStrength}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Good configuration</strong> — subdomain policy is equal to or stricter
|
||||
than main policy.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Weaker subdomain protection</strong> — consider setting
|
||||
<code>sp={dmarcRecord.policy}</code> to match your main policy for consistent
|
||||
protection.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if dmarcRecord.policy}
|
||||
<div class="mb-3">
|
||||
<strong>Subdomain Policy:</strong>
|
||||
<span class="badge bg-info">Inherits main policy</span>
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Good default</strong> — subdomains inherit the main policy (<code
|
||||
>{dmarcRecord.policy}</code
|
||||
>) which provides consistent protection.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Percentage -->
|
||||
{#if dmarcRecord.percentage !== undefined}
|
||||
<div class="mb-3">
|
||||
<strong>Enforcement Percentage:</strong>
|
||||
<span
|
||||
class="badge {dmarcRecord.percentage === 100
|
||||
? 'bg-success'
|
||||
: dmarcRecord.percentage >= 50
|
||||
? 'bg-warning'
|
||||
: 'bg-danger'}"
|
||||
>
|
||||
{dmarcRecord.percentage}%
|
||||
</span>
|
||||
{#if dmarcRecord.percentage === 100}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
||||
This provides maximum protection.
|
||||
</div>
|
||||
{:else if dmarcRecord.percentage >= 50}
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||
messages are subject to DMARC policy. Consider increasing to
|
||||
<code>pct=100</code> once you've validated your configuration.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-danger mt-2 mb-0 small">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||
messages are protected. Gradually increase to <code>pct=100</code> for full
|
||||
protection.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if dmarcRecord.policy}
|
||||
<div class="mb-3">
|
||||
<strong>Enforcement Percentage:</strong>
|
||||
<span class="badge bg-success">100% (default)</span>
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Full enforcement</strong> — all messages are subject to DMARC policy
|
||||
by default.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SPF Alignment -->
|
||||
{#if dmarcRecord.spf_alignment}
|
||||
<div class="mb-3">
|
||||
<strong>SPF Alignment:</strong>
|
||||
<span
|
||||
class="badge {dmarcRecord.spf_alignment === 'strict'
|
||||
? 'bg-success'
|
||||
: 'bg-info'}"
|
||||
>
|
||||
{dmarcRecord.spf_alignment}
|
||||
</span>
|
||||
{#if dmarcRecord.spf_alignment === "relaxed"}
|
||||
<div class="alert alert-info mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Recommended for most senders</strong> — ensures legitimate
|
||||
subdomain mail passes.<br />
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
For maximum brand protection, consider strict alignment (<code
|
||||
>aspf=s</code
|
||||
>) once your sending domains are standardized.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
<strong>Maximum brand protection</strong> — only exact domain matches are
|
||||
accepted. Ensure all legitimate mail comes from the exact From domain.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- DKIM Alignment -->
|
||||
{#if dmarcRecord.dkim_alignment}
|
||||
<div class="mb-3">
|
||||
<strong>DKIM Alignment:</strong>
|
||||
<span
|
||||
class="badge {dmarcRecord.dkim_alignment === 'strict'
|
||||
? 'bg-success'
|
||||
: 'bg-info'}"
|
||||
>
|
||||
{dmarcRecord.dkim_alignment}
|
||||
</span>
|
||||
{#if dmarcRecord.dkim_alignment === "relaxed"}
|
||||
<div class="alert alert-info mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Recommended for most senders</strong> — ensures legitimate
|
||||
subdomain mail passes.<br />
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
For maximum brand protection, consider strict alignment (<code
|
||||
>adkim=s</code
|
||||
>) once your sending domains are standardized.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
<strong>Maximum brand protection</strong> — only exact domain matches are
|
||||
accepted. Ensure all DKIM signatures use the exact From domain.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Record -->
|
||||
{#if dmarcRecord.record}
|
||||
<div class="mb-2">
|
||||
<strong>Record:</strong><br />
|
||||
<code class="d-block mt-1 text-break">{dmarcRecord.record}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if dmarcRecord.error}
|
||||
<div class="text-danger">
|
||||
<strong>Error:</strong>
|
||||
{dmarcRecord.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { DnsResults, DomainAlignment, ReceivedHop } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
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 MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
||||
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
domainAlignment?: DomainAlignment;
|
||||
dnsResults?: DnsResults;
|
||||
dnsGrade?: string;
|
||||
dnsScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
|
||||
}
|
||||
|
||||
let {
|
||||
domainAlignment,
|
||||
dnsResults,
|
||||
dnsGrade,
|
||||
dnsScore,
|
||||
receivedChain,
|
||||
domainOnly = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Extract sender IP from first hop
|
||||
const senderIp = $derived(
|
||||
receivedChain && receivedChain.length > 0 ? receivedChain[0].ip : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="dns-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-diagram-3 me-2"></i>
|
||||
DNS Records
|
||||
</span>
|
||||
<span>
|
||||
{#if dnsScore !== undefined}
|
||||
<span class="badge bg-{getScoreColorClass(dnsScore)}">
|
||||
{dnsScore}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if dnsGrade !== undefined}
|
||||
<GradeDisplay grade={dnsGrade} size="small" />
|
||||
{/if}
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{#if !dnsResults}
|
||||
<p class="text-muted mb-0">No DNS results available</p>
|
||||
{:else}
|
||||
{#if dnsResults.errors && dnsResults.errors.length > 0}
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>Errors:</strong>
|
||||
<ul class="mb-0">
|
||||
{#each dnsResults.errors as error}
|
||||
<li>{error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !domainOnly}
|
||||
<!-- Reverse IP Section -->
|
||||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="mb-3 d-flex align-items-center gap-2">
|
||||
<h4 class="mb-0 text-truncate">
|
||||
Received from: <code
|
||||
>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0]
|
||||
.ip}])</code
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PTR Records Section -->
|
||||
<PtrRecordsDisplay ptrRecords={dnsResults.ptr_records} {senderIp} />
|
||||
|
||||
<!-- Forward-Confirmed Reverse DNS -->
|
||||
<PtrForwardRecordsDisplay
|
||||
ptrRecords={dnsResults.ptr_records}
|
||||
ptrForwardRecords={dnsResults.ptr_forward_records}
|
||||
{senderIp}
|
||||
/>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<!-- Return-Path Domain Section -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h4 class="mb-0 text-truncate">
|
||||
Return-Path Domain:
|
||||
<code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
|
||||
<span class="badge bg-danger ms-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain
|
||||
</span>
|
||||
<small>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<a href="#domain-alignment">See domain alignment</a>
|
||||
</small>
|
||||
{:else}
|
||||
<span class="badge bg-success ms-2">Same as From domain</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MX Records for Return-Path Domain -->
|
||||
{#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0}
|
||||
<MxRecordsDisplay
|
||||
class="mb-4"
|
||||
mxRecords={dnsResults.rp_mx_records}
|
||||
title="Mail Exchange Records for Return-Path Domain"
|
||||
description="These MX records handle bounce messages and non-delivery reports."
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- SPF Records (for Return-Path Domain) -->
|
||||
<SpfRecordsDisplay
|
||||
spfRecords={dnsResults.spf_records}
|
||||
dmarcRecord={dnsResults.dmarc_record}
|
||||
/>
|
||||
|
||||
{#if !domainOnly}
|
||||
<hr class="my-4" />
|
||||
|
||||
<!-- From Domain Section -->
|
||||
<div class="mb-3 d-flex align-items-center gap-2">
|
||||
<h4 class="mb-0 text-truncate">
|
||||
From Domain: <code>{dnsResults.from_domain}</code>
|
||||
</h4>
|
||||
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||
<span class="badge bg-danger ms-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
|
||||
domain
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- MX Records for From Domain -->
|
||||
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
|
||||
<MxRecordsDisplay
|
||||
class="mb-4"
|
||||
mxRecords={dnsResults.from_mx_records}
|
||||
title="Mail Exchange Records for From Domain"
|
||||
description="These MX records handle replies to emails sent from this domain."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !domainOnly}
|
||||
<!-- DKIM Records -->
|
||||
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
|
||||
{/if}
|
||||
|
||||
<!-- DMARC Record -->
|
||||
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
||||
|
||||
<!-- BIMI Record -->
|
||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
}
|
||||
|
||||
let { email }: Props = $props();
|
||||
let copied = $state(false);
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
|
|
@ -18,32 +15,13 @@
|
|||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
copyToClipboard();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded rounded-4 p-4"
|
||||
class:bg-light={$theme === "light"}
|
||||
class:bg-secondary={$theme !== "light"}
|
||||
>
|
||||
<div class="input-group">
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
class="form-control text-center fs-5 text-primary fw-bold font-monospace"
|
||||
value={email}
|
||||
readonly
|
||||
onfocus={handleFocus}
|
||||
/>
|
||||
<div class="bg-light rounded p-4">
|
||||
<div class="d-flex align-items-center justify-content-center gap-3">
|
||||
<code class="fs-5 text-primary fw-bold">{email}</code>
|
||||
<button
|
||||
class="btn btn-outline-primary clipboard-btn"
|
||||
class:btn-outline-primary={$theme === "light"}
|
||||
class:btn-primary={$theme !== "light"}
|
||||
class="btn btn-sm btn-outline-primary clipboard-btn"
|
||||
onclick={copyToClipboard}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
|
|
@ -51,7 +29,7 @@
|
|||
</button>
|
||||
</div>
|
||||
{#if copied}
|
||||
<small class="text-success d-block mt-2 text-center">
|
||||
<small class="text-success d-block mt-2">
|
||||
<i class="bi bi-check2"></i> Copied to clipboard!
|
||||
</small>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
interface Props {
|
||||
receivedChain: ReceivedHop[];
|
||||
}
|
||||
|
||||
let { receivedChain }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="card shadow-sm" id="email-path">
|
||||
<div
|
||||
class="card-header"
|
||||
class:bg-white={$theme === "light"}
|
||||
class:bg-dark={$theme !== "light"}
|
||||
>
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-pin-map me-2"></i>
|
||||
Email Path
|
||||
</h4>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{#each receivedChain as hop, i}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
|
||||
{hop.reverse || "-"}
|
||||
{#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if} → {hop.by ||
|
||||
"Unknown"}
|
||||
</h6>
|
||||
<small class="text-muted" title={hop.timestamp}>
|
||||
{hop.timestamp
|
||||
? new Intl.DateTimeFormat("default", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(hop.timestamp))
|
||||
: "-"}
|
||||
</small>
|
||||
</div>
|
||||
{#if hop.with || hop.id || hop.from}
|
||||
<p class="mb-1 small d-flex gap-3">
|
||||
{#if hop.with}
|
||||
<span>
|
||||
<span class="text-muted">Protocol:</span>
|
||||
<code>{hop.with}</code>
|
||||
</span>
|
||||
{/if}
|
||||
{#if hop.id}
|
||||
<span>
|
||||
<span class="text-muted">ID:</span> <code>{hop.id}</code>
|
||||
</span>
|
||||
{/if}
|
||||
{#if hop.from}
|
||||
<span>
|
||||
<span class="text-muted">Helo:</span> <code>{hop.from}</code>
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
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