Compare commits
209 commits
renovate/s
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b6154c183 | |||
| 56e6494a75 | |||
| 0176c3803d | |||
| 21e16fd847 | |||
| edfe498b27 | |||
| 27650a3496 | |||
| d9b9ea87c6 | |||
| bb47bb7c29 | |||
| da93d6d706 | |||
| 2a2bfe46a8 | |||
| 55e9bcd3d0 | |||
| 28424729a5 | |||
| 3cc39c9c54 | |||
| f9c5c815d1 | |||
| 4245f93ce4 | |||
| 9679b381c7 | |||
| 7b9c45fb68 | |||
| b619ebf8c3 | |||
| a146940a65 | |||
| e811d02b3b | |||
| 8fda7746a1 | |||
| 96e83ff70d | |||
| 6b983f0506 | |||
| c50e18a347 | |||
| 054cd8ae25 | |||
| c2917f8580 | |||
| b39a9dc625 | |||
| 88553cd3c8 | |||
| 8a10eef2f5 | |||
| 64ba6932f7 | |||
| 5453c09420 | |||
| 6b4ca126b0 | |||
| ac9b567025 | |||
| 035e864de4 | |||
| a6efd7710e | |||
| e6746a1382 | |||
| d1e48b9885 | |||
| 9ac3e165fa | |||
| dc21b72f52 | |||
| 1ba35c6f9f | |||
| 0fda0f88c1 | |||
| 57a3774d28 | |||
| 11d46de033 | |||
| 6081e486bf | |||
| 528a65ca04 | |||
| 926796b79e | |||
| 5d02070100 | |||
| 5701070cc1 | |||
| 954cbe29fc | |||
| ca2ac3df7c | |||
| 016ed7180e | |||
| 3e76692448 | |||
| e23afcc77c | |||
| d81ff1731c | |||
| eef6480e75 | |||
| f2261adb54 | |||
| 3bcbb5814d | |||
| 5ac0e2a8bf | |||
| a1e8dd35bd | |||
| e194fcc5b1 | |||
| c19f545df0 | |||
| 03b58b6f19 | |||
| a3ca8ffb48 | |||
| 27d5220687 | |||
| 723bec622a | |||
| ee9fa59dbc | |||
| e05c6d0bc2 | |||
| 04d8b150b4 | |||
| e28a96508d | |||
| ea71074cc8 | |||
| 644dfda223 | |||
| 447a666ae7 | |||
| 2172603ad5 | |||
| c91ab96642 | |||
| 18c8622513 | |||
| deb9fd4f51 | |||
| c52a3aa8a7 | |||
| 5b179e7b93 | |||
| 465da6d16a | |||
| d870fc8130 | |||
| 1c4eb0653e | |||
| 372c9c5153 | |||
| 3b301a415f | |||
| 7231669362 | |||
| bc6a6397ad | |||
| e166e75e42 | |||
| d3f69630c9 | |||
| 9e9e76cf42 | |||
| 65c8e9a528 | |||
| 718b624fb8 | |||
| 099965c1f9 | |||
| 90dda126ad | |||
| 3a8a25ddeb | |||
| b01ca9b38c | |||
| 20fe4e5b97 | |||
| 706dc6eed9 | |||
| 164b2a98ab | |||
| dccf75b238 | |||
| f0dbc29da4 | |||
| 8769514f1c | |||
| 871f4e62f6 | |||
| 86ec7a6100 | |||
| 2d3316eaaf | |||
| 730b43cad1 | |||
| ff5ac0fe1f | |||
| b95e5d6732 | |||
| 0325139461 | |||
| 07c7e63ee7 | |||
| faf860f4a1 | |||
| 6d2a59dd7b | |||
| 39185f82bd | |||
| dd14d7a814 | |||
| 924c54912a | |||
| 20513bfc00 | |||
| b98988c153 | |||
| 386951b1eb | |||
| 1a373d1942 | |||
| 426fad9856 | |||
| 2bedd0ed75 | |||
| cdfdeb74fc | |||
| 3c5bd5cbbd | |||
| c5bb9e46ac | |||
| 4c71dd1d53 | |||
| 76de5f60d6 | |||
| 82373cdaac | |||
| edb172c4bc | |||
| 53a48cba07 | |||
| 08c6e0eef2 | |||
| 932bc981b5 | |||
| b2dc479a79 | |||
| 6e2e403873 | |||
| eb28499dfd | |||
| ff1a958220 | |||
| 38b2be58fe | |||
| 52f43c6bc5 | |||
| 3ea958b2fd | |||
| a700db0873 | |||
| 115da72874 | |||
| 255027d00b | |||
| 9970e957d5 | |||
| 8fe8581b78 | |||
| aa35ab223d | |||
| 29cb2cf1f9 | |||
| 7ed347c86e | |||
| 4bbba66a81 | |||
| 8b3ab541ba | |||
| 3588af3267 | |||
| c1063cb4aa | |||
| 3d03bfc4fa | |||
| 84a504d668 | |||
| 7bc7e7b7a2 | |||
| 326abc0744 | |||
| a6448a1533 | |||
| e5c678174c | |||
| 5d335c6a6c | |||
| a97729fea6 | |||
| 4149a5de92 | |||
| 8ca4bed875 | |||
| f6a1ea73a2 | |||
| a64b866cfa | |||
| 33d394a27b | |||
| 41013d8af2 | |||
| e77bffb04f | |||
| abfd1f0155 | |||
| fdb43533cd | |||
| eb210e7bed | |||
| c51f8e5904 | |||
| 866cf2e5db | |||
| eadc7ff8ca | |||
| 0581e0cf6b | |||
| ec1ab7886e | |||
| d87b0cbcb0 | |||
| 954a9d705e | |||
| 1be917136c | |||
| 74aee54432 | |||
| dfc0eeb323 | |||
| 0ac51ac06d | |||
| e3d89dc953 | |||
| 8bf3b500d9 | |||
| 0107858ee6 | |||
| 4d637214de | |||
| 849bdb53c5 | |||
| 3f5e2c6dd4 | |||
| 0084fd9660 | |||
| a687f5cb6b | |||
| 243ca4ba11 | |||
| 3c58f5ccd5 | |||
| 1fa7af4c2b | |||
| 30f774c1fb | |||
| cd40b7c3ea | |||
| 8313fd7d98 | |||
| 6097eb54c6 | |||
| 7e603ddf4a | |||
| e7aa80bef4 | |||
| b2bbf0ee78 | |||
| 6096e043c6 | |||
| 9ff2ca30cc | |||
| 4304784796 | |||
| 6565c6fda4 | |||
| 079dc6a813 | |||
| 16a0f3a158 | |||
| 20f5b37e5e | |||
| 3867fa36a2 | |||
| 18c2f95112 | |||
| 4b9733531e | |||
| 682ca6bb20 | |||
| 3d823dedd8 | |||
| 62bb85ebec | |||
| 449a8a2c67 |
162 changed files with 26290 additions and 6456 deletions
27
.dockerignore
Normal file
27
.dockerignore
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Build artifacts
|
||||
happyDeliver
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs files
|
||||
logs/
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
testdata/
|
||||
22
.drone-manifest.yml
Normal file
22
.drone-manifest.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||
{{#if build.tags}}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
- image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||
platform:
|
||||
architecture: amd64
|
||||
os: linux
|
||||
- image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||
platform:
|
||||
architecture: arm64
|
||||
os: linux
|
||||
variant: v8
|
||||
- image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||
platform:
|
||||
architecture: arm
|
||||
os: linux
|
||||
variant: v7
|
||||
156
.drone.yml
Normal file
156
.drone.yml
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-arm64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: frontend
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- cd web
|
||||
- npm install --network-timeout=100000
|
||||
- npm run generate:api
|
||||
- npm run build
|
||||
|
||||
- name: backend-commit
|
||||
image: golang:1-alpine
|
||||
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
|
||||
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: backend-tag
|
||||
image: golang:1-alpine
|
||||
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/
|
||||
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: build-commit macOS
|
||||
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/
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: darwin
|
||||
GOARCH: arm64
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: build-tag macOS
|
||||
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/
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: darwin
|
||||
GOARCH: arm64
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: happydomain/happydeliver
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
exclude:
|
||||
- renovate/*
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-amd64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: happydomain/happydeliver
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
exclude:
|
||||
- renovate/*
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: docker-manifest
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
auto_tag: true
|
||||
ignore_missing: true
|
||||
spec: .drone-manifest.yml
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
exclude:
|
||||
- renovate/*
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
depends_on:
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,6 +17,9 @@ vendor/
|
|||
.env.local
|
||||
*.local
|
||||
|
||||
# Logs files
|
||||
logs/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
|
|
|
|||
189
Dockerfile
Normal file
189
Dockerfile
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Multi-stage Dockerfile for happyDeliver with integrated MTA
|
||||
# Stage 1: Build the Svelte application
|
||||
FROM node:24-alpine AS nodebuild
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY api/ api/
|
||||
COPY web/ web/
|
||||
|
||||
RUN yarn --cwd web install && \
|
||||
yarn --cwd web run generate:api && \
|
||||
yarn --cwd web --offline build
|
||||
|
||||
# Stage 2: Build the Go application
|
||||
FROM golang:1-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache ca-certificates git gcc musl-dev
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
COPY --from=nodebuild /build/web/build/ ./web/build/
|
||||
|
||||
# Build the application
|
||||
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
|
||||
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 \
|
||||
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 \
|
||||
postfix \
|
||||
postfix-pcre \
|
||||
rspamd \
|
||||
spamassassin \
|
||||
spamassassin-client \
|
||||
supervisor \
|
||||
sqlite \
|
||||
tzdata \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Copy Mail::Milter::Authentication and its dependancies
|
||||
COPY --from=pl /usr/local/ /usr/local/
|
||||
|
||||
# Create happydeliver user and group
|
||||
RUN addgroup -g 1000 happydeliver && \
|
||||
adduser -D -u 1000 -G happydeliver happydeliver
|
||||
|
||||
# Create necessary directories
|
||||
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 \
|
||||
&& 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
|
||||
|
||||
# Copy the built application
|
||||
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||
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/spamassassin/ /etc/mail/spamassassin/
|
||||
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
|
||||
COPY docker/supervisor/ /etc/supervisor/
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Expose ports
|
||||
# 25 - SMTP
|
||||
# 8080 - API server
|
||||
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
|
||||
|
||||
# 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"]
|
||||
285
README.md
Normal file
285
README.md
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
# happyDeliver - Email Deliverability Tester
|
||||
|
||||

|
||||
|
||||
An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring.
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
|
||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
|
||||
- **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.
|
||||
|
||||
#### What's included in the Docker container:
|
||||
|
||||
- **Postfix MTA**: Receives emails on port 25
|
||||
- **authentication_milter**: Entreprise grade email authentication
|
||||
- **SpamAssassin**: Spam scoring and analysis
|
||||
- **rspamd**: Second spam filter for cross-validated scoring
|
||||
- **happyDeliver API**: REST API server on port 8080
|
||||
- **SQLite Database**: Persistent storage for tests and reports
|
||||
|
||||
#### 1. Using docker-compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
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
|
||||
|
||||
# Build and start
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8080` and SMTP at `localhost:25`.
|
||||
|
||||
#### 2. Using docker build directly
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t happydeliver:latest .
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name happydeliver \
|
||||
-p 25:25 \
|
||||
-p 8080:8080 \
|
||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||
--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
|
||||
|
||||
```bash
|
||||
go generate
|
||||
go build -o happyDeliver ./cmd/happyDeliver
|
||||
```
|
||||
|
||||
### 2. Run the API Server
|
||||
|
||||
```bash
|
||||
./happyDeliver server
|
||||
```
|
||||
|
||||
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, ...
|
||||
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:
|
||||
|
||||
#### Postfix LMTP Transport
|
||||
|
||||
You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP.
|
||||
|
||||
1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`):
|
||||
|
||||
```bash
|
||||
./happyDeliver server
|
||||
```
|
||||
|
||||
You can customize the LMTP address with the `-lmtp-addr` flag or in the config file.
|
||||
|
||||
2. Create the file `/etc/postfix/transport_happydeliver` with the following content:
|
||||
|
||||
```
|
||||
# Transport map - route test emails to happyDeliver LMTP server
|
||||
# Pattern: test-<base32-uuid>@yourdomain.com -> LMTP on localhost:2525
|
||||
|
||||
/^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
|
||||
```
|
||||
|
||||
3. Append the created file to `transport_maps` in your `main.cf`:
|
||||
|
||||
```diff
|
||||
-transport_maps = texthash:/etc/postfix/transport
|
||||
+transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_happydeliver
|
||||
```
|
||||
|
||||
If your `transport_maps` option is not set, just append this line:
|
||||
|
||||
```
|
||||
transport_maps = pcre:/etc/postfix/transport_happydeliver
|
||||
```
|
||||
|
||||
Note: to use the `pcre:` type, you need to have `postfix-pcre` installed.
|
||||
|
||||
4. Reload Postfix configuration:
|
||||
|
||||
```bash
|
||||
postfix reload
|
||||
```
|
||||
|
||||
#### 4. Create a Test
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/test
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
|
||||
"status": "pending",
|
||||
"message": "Send your test email to the address above"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Send Test Email
|
||||
|
||||
Send a test email to the address provided (you'll need to configure your MTA to route emails to the analyzer - see MTA Integration below).
|
||||
|
||||
#### 6. Get Report
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/test` | POST | Create a new deliverability test |
|
||||
| `/api/test/{id}` | GET | Get test metadata and status |
|
||||
| `/api/report/{id}` | GET | Get detailed analysis report |
|
||||
| `/api/report/{id}/raw` | GET | Get raw annotated email |
|
||||
| `/api/status` | GET | Service health and status |
|
||||
|
||||
## Email Analyzer (CLI Mode)
|
||||
|
||||
For manual testing or debugging, you can analyze emails from the command line:
|
||||
|
||||
```bash
|
||||
cat email.eml | ./happyDeliver analyze
|
||||
```
|
||||
|
||||
Or specify recipient explicitly:
|
||||
|
||||
```bash
|
||||
cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
|
||||
```
|
||||
|
||||
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
|
||||
|
||||
## Scoring System
|
||||
|
||||
The deliverability score is calculated from A to F 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
|
||||
|
||||
## Funding
|
||||
|
||||
This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/happyDomain).
|
||||
|
||||
[<img src="https://nlnet.nl/logo/banner.png" alt="NLnet foundation logo" width="20%" />](https://nlnet.nl)
|
||||
[<img src="https://nlnet.nl/image/logos/NGI0_tag.svg" alt="NGI Zero Logo" width="20%" />](https://nlnet.nl/core)
|
||||
|
||||
## License
|
||||
|
||||
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
|
||||
1135
api/openapi.yaml
1135
api/openapi.yaml
File diff suppressed because it is too large
Load diff
BIN
banner.webp
Normal file
BIN
banner.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -22,31 +22,50 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/app"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Mail Tester - Email Deliverability Testing Platform")
|
||||
fmt.Println("Version: 0.1.0-dev")
|
||||
fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform")
|
||||
fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version)
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
cfg, err := config.ConsolidateConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
command := flag.Arg(0)
|
||||
|
||||
switch command {
|
||||
case "server":
|
||||
log.Println("Starting API server...")
|
||||
// TODO: Start API server
|
||||
if err := app.RunServer(cfg); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
case "analyze":
|
||||
log.Println("Starting email analyzer...")
|
||||
// TODO: Start email analyzer (LMTP/pipe mode)
|
||||
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("0.1.0-dev")
|
||||
fmt.Println(version.Version)
|
||||
default:
|
||||
fmt.Printf("Unknown command: %s\n", command)
|
||||
printUsage()
|
||||
|
|
@ -55,8 +74,12 @@ func main() {
|
|||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("\nUsage:")
|
||||
fmt.Println(" mailtester server - Start the API server")
|
||||
fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)")
|
||||
fmt.Println(" mailtester version - Print version information")
|
||||
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("")
|
||||
flag.Usage()
|
||||
}
|
||||
|
|
|
|||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
services:
|
||||
happydeliver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: happydomain/happydeliver:latest
|
||||
container_name: happydeliver
|
||||
# Set a hostname
|
||||
hostname: mail.happydeliver.local
|
||||
|
||||
environment:
|
||||
# Set your domain
|
||||
HAPPYDELIVER_DOMAIN: happydeliver.local
|
||||
|
||||
ports:
|
||||
# SMTP port
|
||||
- "25:25"
|
||||
# API port
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
# Persistent database storage
|
||||
- ./data:/var/lib/happydeliver
|
||||
# Log files
|
||||
- ./logs:/var/log/happydeliver
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
logs:
|
||||
165
docker/README.md
Normal file
165
docker/README.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# happyDeliver Docker Configuration
|
||||
|
||||
This directory contains all configuration files for the all-in-one Docker container.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Docker container integrates multiple components:
|
||||
|
||||
- **Postfix**: Mail Transfer Agent (MTA) that receives emails on port 25
|
||||
- **OpenDKIM**: DKIM signature verification
|
||||
- **OpenDMARC**: DMARC policy validation
|
||||
- **SpamAssassin**: Spam scoring and content analysis
|
||||
- **happyDeliver**: Go application (API server + email analyzer)
|
||||
- **Supervisor**: Process manager that runs all services
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
docker/
|
||||
├── postfix/
|
||||
│ ├── main.cf # Postfix main configuration
|
||||
│ ├── master.cf # Postfix service definitions
|
||||
│ └── transport_maps # Email routing rules
|
||||
├── opendkim/
|
||||
│ └── opendkim.conf # DKIM verification config
|
||||
├── opendmarc/
|
||||
│ └── opendmarc.conf # DMARC validation config
|
||||
├── spamassassin/
|
||||
│ └── local.cf # SpamAssassin rules and scoring
|
||||
├── supervisor/
|
||||
│ └── supervisord.conf # Supervisor service definitions
|
||||
├── entrypoint.sh # Container initialization script
|
||||
└── config.docker.yaml # happyDeliver default config
|
||||
```
|
||||
|
||||
## Configuration Details
|
||||
|
||||
### Postfix (postfix/)
|
||||
|
||||
**main.cf**: Core Postfix settings
|
||||
- Configures hostname, domain, and network interfaces
|
||||
- Sets up milter integration for OpenDKIM and OpenDMARC
|
||||
- Configures SPF policy checking
|
||||
- Routes emails through SpamAssassin content filter
|
||||
- Uses transport_maps to route test emails to happyDeliver
|
||||
|
||||
**master.cf**: Service definitions
|
||||
- Defines SMTP service with content filtering
|
||||
- Sets up SPF policy service (postfix-policyd-spf-perl)
|
||||
- Configures SpamAssassin content filter
|
||||
- Defines happydeliver pipe for email analysis
|
||||
|
||||
**transport_maps**: PCRE-based routing
|
||||
- Matches test-UUID@domain emails
|
||||
- Routes them to the happydeliver pipe
|
||||
|
||||
### OpenDKIM (opendkim/)
|
||||
|
||||
**opendkim.conf**: DKIM verification settings
|
||||
- Operates in verification-only mode
|
||||
- Adds Authentication-Results headers
|
||||
- Socket communication with Postfix via milter
|
||||
- 5-second DNS timeout
|
||||
|
||||
### OpenDMARC (opendmarc/)
|
||||
|
||||
**opendmarc.conf**: DMARC validation settings
|
||||
- Validates DMARC policies
|
||||
- Adds results to Authentication-Results headers
|
||||
- Does not reject emails (analysis mode only)
|
||||
- Socket communication with Postfix via milter
|
||||
|
||||
### SpamAssassin (spamassassin/)
|
||||
|
||||
**local.cf**: Spam detection rules
|
||||
- Enables network tests (RBL checks)
|
||||
- SPF and DKIM checking
|
||||
- Required score: 5.0 (standard threshold)
|
||||
- Adds detailed spam report headers
|
||||
- 5-second RBL timeout
|
||||
|
||||
### Supervisor (supervisor/)
|
||||
|
||||
**supervisord.conf**: Service orchestration
|
||||
- Runs all services as daemons
|
||||
- Start order: OpenDKIM → OpenDMARC → SpamAssassin → Postfix → API
|
||||
- Automatic restart on failure
|
||||
- Centralized logging
|
||||
|
||||
### Entrypoint Script (entrypoint.sh)
|
||||
|
||||
Initialization script that:
|
||||
1. Creates required directories and sets permissions
|
||||
2. Replaces configuration placeholders with environment variables
|
||||
3. Initializes Postfix (aliases, transport maps)
|
||||
4. Updates SpamAssassin rules
|
||||
5. Starts Supervisor to launch all services
|
||||
|
||||
### happyDeliver Config (config.docker.yaml)
|
||||
|
||||
Default configuration for the Docker environment:
|
||||
- API server on 0.0.0.0:8080
|
||||
- SQLite database at /var/lib/happydeliver/happydeliver.db
|
||||
- Configurable domain for test emails
|
||||
- RBL servers for blacklist checking
|
||||
- Timeouts for DNS and HTTP checks
|
||||
|
||||
## Environment Variables
|
||||
|
||||
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.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
**Required volumes:**
|
||||
- `/var/lib/happydeliver`: Database and persistent data
|
||||
- `/var/log/happydeliver`: Log files from all services
|
||||
|
||||
**Optional volumes:**
|
||||
- `/etc/happydeliver/config.yaml`: Custom configuration file
|
||||
|
||||
## Ports
|
||||
|
||||
- **25**: SMTP (Postfix)
|
||||
- **8080**: HTTP API (happyDeliver)
|
||||
|
||||
## Service Startup Order
|
||||
|
||||
Supervisor ensures services start in the correct order:
|
||||
|
||||
1. **OpenDKIM** (priority 10): DKIM verification milter
|
||||
2. **OpenDMARC** (priority 11): DMARC validation milter
|
||||
3. **SpamAssassin** (priority 12): Spam scoring daemon
|
||||
4. **Postfix** (priority 20): MTA that uses the above services
|
||||
5. **happyDeliver API** (priority 30): REST API server
|
||||
|
||||
## Email Processing Flow
|
||||
|
||||
1. Email arrives at Postfix on port 25
|
||||
2. Postfix sends to OpenDKIM milter
|
||||
- Verifies DKIM signature
|
||||
- Adds `Authentication-Results: ... dkim=pass/fail`
|
||||
3. Postfix sends to OpenDMARC milter
|
||||
- Validates DMARC policy
|
||||
- Adds `Authentication-Results: ... dmarc=pass/fail`
|
||||
4. Postfix routes through SpamAssassin content filter
|
||||
- Checks SPF record
|
||||
- Scores email for spam
|
||||
- Adds `X-Spam-Status` and `X-Spam-Report` headers
|
||||
5. Postfix checks transport_maps
|
||||
- If recipient matches test-UUID pattern, route to happydeliver pipe
|
||||
6. happyDeliver analyzer receives email
|
||||
- Extracts test ID from recipient
|
||||
- Parses all headers added by filters
|
||||
- Performs additional analysis (DNS, RBL, content)
|
||||
- Generates deliverability score
|
||||
- Stores report in database
|
||||
75
docker/authentication_milter/authentication_milter.json
Normal file
75
docker/authentication_milter/authentication_milter.json
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"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" : {}
|
||||
}
|
||||
}
|
||||
58
docker/authentication_milter/mail-dmarc.ini
Normal file
58
docker/authentication_milter/mail-dmarc.ini
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
; 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 =
|
||||
74
docker/entrypoint.sh
Normal file
74
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting happyDeliver container..."
|
||||
|
||||
# Get environment variables with defaults
|
||||
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
|
||||
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
|
||||
|
||||
mkdir -p /var/spool/postfix/rspamd
|
||||
chown rspamd:mail /var/spool/postfix/rspamd
|
||||
chmod 750 /var/spool/postfix/rspamd
|
||||
|
||||
# Create log directory
|
||||
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
|
||||
chown happydeliver:happydeliver /var/log/happydeliver
|
||||
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
|
||||
|
||||
# Initialize Postfix aliases
|
||||
if [ -f /etc/postfix/aliases ]; then
|
||||
echo "Initializing Postfix aliases..."
|
||||
postalias /etc/postfix/aliases || true
|
||||
fi
|
||||
|
||||
# Compile transport maps
|
||||
if [ -f /etc/postfix/transport_maps ]; then
|
||||
echo "Compiling transport maps..."
|
||||
postmap /etc/postfix/transport_maps
|
||||
fi
|
||||
|
||||
# Update SpamAssassin rules
|
||||
echo "Updating SpamAssassin rules..."
|
||||
sa-update || echo "SpamAssassin rules update failed (might be first run)"
|
||||
|
||||
# Compile SpamAssassin rules
|
||||
sa-compile || echo "SpamAssassin compilation skipped"
|
||||
|
||||
# Initialize database if it doesn't exist
|
||||
if [ ! -f /var/lib/happydeliver/happydeliver.db ]; then
|
||||
echo "Database will be initialized on first API startup..."
|
||||
fi
|
||||
|
||||
# Set proper permissions
|
||||
chown -R happydeliver:happydeliver /var/lib/happydeliver
|
||||
|
||||
echo "Configuration complete, starting services..."
|
||||
|
||||
# Execute the main command (supervisord)
|
||||
exec "$@"
|
||||
10
docker/postfix/aliases
Normal file
10
docker/postfix/aliases
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Postfix aliases for happyDeliver
|
||||
# This file is processed by postalias to create aliases.db
|
||||
|
||||
# Standard aliases
|
||||
postmaster: root
|
||||
abuse: root
|
||||
mailer-daemon: postmaster
|
||||
|
||||
# Root mail can be redirected if needed
|
||||
# root: admin@example.com
|
||||
40
docker/postfix/main.cf
Normal file
40
docker/postfix/main.cf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Postfix main configuration for happyDeliver
|
||||
# This configuration receives emails and routes them through authentication filters
|
||||
|
||||
# Basic settings
|
||||
compatibility_level = 3.6
|
||||
myhostname = __HOSTNAME__
|
||||
mydomain = __DOMAIN__
|
||||
myorigin = $mydomain
|
||||
inet_interfaces = all
|
||||
inet_protocols = ipv4
|
||||
|
||||
# Recipient settings
|
||||
mydestination = localhost.$mydomain, localhost
|
||||
mynetworks = 127.0.0.0/8 [::1]/128
|
||||
|
||||
# Relay settings - accept mail for our test domain
|
||||
relay_domains = $mydomain
|
||||
|
||||
# Queue and size limits
|
||||
message_size_limit = 10485760
|
||||
mailbox_size_limit = 0
|
||||
queue_minfree = 50000000
|
||||
|
||||
# Transport maps - route test emails to happyDeliver analyzer
|
||||
transport_maps = pcre:/etc/postfix/transport_maps
|
||||
|
||||
# Authentication milters
|
||||
# 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
|
||||
non_smtpd_milters = $smtpd_milters
|
||||
|
||||
# SPF policy checking
|
||||
smtpd_recipient_restrictions =
|
||||
permit_mynetworks,
|
||||
reject_unauth_destination
|
||||
|
||||
# Logging
|
||||
debug_peer_level = 2
|
||||
78
docker/postfix/master.cf
Normal file
78
docker/postfix/master.cf
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Postfix master process configuration for happyDeliver
|
||||
|
||||
# SMTP service
|
||||
smtp inet n - n - - smtpd
|
||||
|
||||
# Pickup service
|
||||
pickup unix n - n 60 1 pickup
|
||||
|
||||
# Cleanup service
|
||||
cleanup unix n - n - 0 cleanup
|
||||
|
||||
# Queue manager
|
||||
qmgr unix n - n 300 1 qmgr
|
||||
|
||||
# Rewrite service
|
||||
rewrite unix - - n - - trivial-rewrite
|
||||
|
||||
# Bounce service
|
||||
bounce unix - - n - 0 bounce
|
||||
|
||||
# Defer service
|
||||
defer unix - - n - 0 bounce
|
||||
|
||||
# Trace service
|
||||
trace unix - - n - 0 bounce
|
||||
|
||||
# Verify service
|
||||
verify unix - - n - 1 verify
|
||||
|
||||
# Flush service
|
||||
flush unix n - n 1000? 0 flush
|
||||
|
||||
# Proxymap service
|
||||
proxymap unix - - n - - proxymap
|
||||
|
||||
# Proxywrite service
|
||||
proxywrite unix - - n - 1 proxymap
|
||||
|
||||
# SMTP client
|
||||
smtp unix - - n - - smtp
|
||||
|
||||
# Relay service
|
||||
relay unix - - n - - smtp
|
||||
|
||||
# Showq service
|
||||
showq unix n - n - - showq
|
||||
|
||||
# Error service
|
||||
error unix - - n - - error
|
||||
|
||||
# Retry service
|
||||
retry unix - - n - - error
|
||||
|
||||
# Discard service
|
||||
discard unix - - n - - discard
|
||||
|
||||
# Local delivery
|
||||
local unix - n n - - local
|
||||
|
||||
# Virtual delivery
|
||||
virtual unix - n n - - virtual
|
||||
|
||||
# LMTP delivery
|
||||
lmtp unix - - n - - lmtp
|
||||
|
||||
# Anvil service
|
||||
anvil unix - - n - 1 anvil
|
||||
|
||||
# Scache service
|
||||
scache unix - - n - 1 scache
|
||||
|
||||
# Maildrop service
|
||||
maildrop unix - n n - - pipe
|
||||
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
|
||||
|
||||
# SpamAssassin content filter
|
||||
spamassassin unix - n n - - pipe
|
||||
user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient}
|
||||
4
docker/postfix/transport_maps
Normal file
4
docker/postfix/transport_maps
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Transport map - route test emails to happyDeliver LMTP server
|
||||
# Pattern: test-<base32-uuid>@domain.com -> LMTP on localhost:2525
|
||||
|
||||
/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525
|
||||
5
docker/rspamd/local.d/actions.conf
Normal file
5
docker/rspamd/local.d/actions.conf
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
no_action = 0;
|
||||
reject = null;
|
||||
add_header = null;
|
||||
rewrite_subject = null;
|
||||
greylist = null;
|
||||
5
docker/rspamd/local.d/milter_headers.conf
Normal file
5
docker/rspamd/local.d/milter_headers.conf
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Add "extended Rspamd headers"
|
||||
extended_spam_headers = true;
|
||||
|
||||
skip_local = false;
|
||||
skip_authenticated = false;
|
||||
3
docker/rspamd/local.d/options.inc
Normal file
3
docker/rspamd/local.d/options.inc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# rspamd options for happyDeliver
|
||||
# Disable Bayes learning to keep the setup stateless
|
||||
use_redis = false;
|
||||
6
docker/rspamd/local.d/worker-proxy.inc
Normal file
6
docker/rspamd/local.d/worker-proxy.inc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Enable rspamd milter proxy worker via Unix socket for Postfix integration
|
||||
bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
|
||||
upstream "local" {
|
||||
default = yes;
|
||||
self_scan = yes;
|
||||
}
|
||||
61
docker/spamassassin/local.cf
Normal file
61
docker/spamassassin/local.cf
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# SpamAssassin configuration for happyDeliver
|
||||
# Scores emails for spam characteristics
|
||||
|
||||
# Network tests
|
||||
# Enable network tests for RBL checks, Razor, Pyzor, etc.
|
||||
use_network_tests 1
|
||||
|
||||
# RBL checks
|
||||
# Enable DNS-based blacklist checks
|
||||
use_rbls 1
|
||||
|
||||
# SPF checking
|
||||
use_spf 1
|
||||
|
||||
# DKIM checking
|
||||
use_dkim 1
|
||||
|
||||
# Bayes filtering
|
||||
# Disable bayes learning (we're not maintaining a persistent spam database)
|
||||
use_bayes 0
|
||||
bayes_auto_learn 0
|
||||
|
||||
# Scoring thresholds
|
||||
# Lower thresholds for testing purposes
|
||||
required_score 5.0
|
||||
|
||||
# Report settings
|
||||
# Add detailed spam report to headers
|
||||
add_header all Status "_YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_"
|
||||
add_header all Level _STARS(*)_
|
||||
add_header all Report _REPORT_
|
||||
|
||||
# Rewrite subject line
|
||||
rewrite_header Subject [SPAM:_SCORE_]
|
||||
|
||||
# Whitelisting and blacklisting
|
||||
# Accept all mail for analysis (don't reject)
|
||||
skip_rbl_checks 0
|
||||
|
||||
# Language settings
|
||||
# Accept all languages
|
||||
ok_languages all
|
||||
|
||||
# Network timeout
|
||||
rbl_timeout 5
|
||||
|
||||
# User preferences
|
||||
# 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
|
||||
87
docker/supervisor/supervisord.conf
Normal file
87
docker/supervisor/supervisord.conf
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/happydeliver/supervisord.log
|
||||
pidfile=/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
[unix_http_server]
|
||||
file=/run/supervisord.sock
|
||||
chmod=0700
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/supervisord.sock
|
||||
|
||||
# syslogd service
|
||||
[program:syslogd]
|
||||
command=/sbin/syslogd -n
|
||||
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
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_logfile=/var/log/happydeliver/authentication_milter.log
|
||||
stderr_logfile=/var/log/happydeliver/authentication_milter.log
|
||||
user=mail
|
||||
group=mail
|
||||
|
||||
# rspamd spam filter
|
||||
[program:rspamd]
|
||||
command=/usr/bin/rspamd -f -u rspamd -g mail
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=11
|
||||
stdout_logfile=/var/log/happydeliver/rspamd.log
|
||||
stderr_logfile=/var/log/happydeliver/rspamd_error.log
|
||||
user=root
|
||||
|
||||
# SpamAssassin daemon
|
||||
[program:spamd]
|
||||
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=12
|
||||
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
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/var/log/happydeliver/postfix.log
|
||||
stderr_logfile=/var/log/happydeliver/postfix_error.log
|
||||
user=root
|
||||
|
||||
# happyDeliver API server
|
||||
[program:happydeliver-api]
|
||||
command=/usr/local/bin/happyDeliver server -config /etc/happydeliver/config.yaml
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=30
|
||||
stdout_logfile=/var/log/happydeliver/api.log
|
||||
stderr_logfile=/var/log/happydeliver/api_error.log
|
||||
user=happydeliver
|
||||
environment=GIN_MODE="release"
|
||||
66
go.mod
66
go.mod
|
|
@ -3,58 +3,76 @@ module git.happydns.org/happyDeliver
|
|||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/getkin/kin-openapi v0.132.0
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/net v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // 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
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
143
go.sum
143
go.sum
|
|
@ -1,11 +1,21 @@
|
|||
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/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/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/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=
|
||||
|
|
@ -14,39 +24,47 @@ 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=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.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/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/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=
|
||||
|
|
@ -68,6 +86,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
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/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=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
|
|
@ -84,10 +114,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -98,8 +130,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.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||
github.com/oapi-codegen/runtime v1.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=
|
||||
|
|
@ -124,10 +156,12 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
|
|||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
|
|
@ -140,33 +174,38 @@ 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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
|
@ -174,13 +213,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
|||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -196,21 +235,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -223,8 +262,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
|
@ -242,3 +281,9 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -1,511 +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"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// AuthenticationAnalyzer analyzes email authentication results
|
||||
type AuthenticationAnalyzer struct{}
|
||||
|
||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
||||
return &AuthenticationAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
|
||||
results := &api.AuthenticationResults{}
|
||||
|
||||
// Parse Authentication-Results headers
|
||||
authHeaders := email.GetAuthenticationResults()
|
||||
for _, header := range authHeaders {
|
||||
a.parseAuthenticationResultsHeader(header, results)
|
||||
}
|
||||
|
||||
// If no Authentication-Results headers, try to parse legacy headers
|
||||
if results.Spf == nil {
|
||||
results.Spf = a.parseLegacySPF(email)
|
||||
}
|
||||
|
||||
if results.Dkim == nil || len(*results.Dkim) == 0 {
|
||||
dkimResults := a.parseLegacyDKIM(email)
|
||||
if len(dkimResults) > 0 {
|
||||
results.Dkim = &dkimResults
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// parseAuthenticationResultsHeader parses an Authentication-Results header
|
||||
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
|
||||
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
|
||||
// Split by semicolon to get individual results
|
||||
parts := strings.Split(header, ";")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the authserv-id (first part)
|
||||
for i := 1; i < len(parts); i++ {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse SPF
|
||||
if strings.HasPrefix(part, "spf=") {
|
||||
if results.Spf == nil {
|
||||
results.Spf = a.parseSPFResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse DKIM
|
||||
if strings.HasPrefix(part, "dkim=") {
|
||||
dkimResult := a.parseDKIMResult(part)
|
||||
if dkimResult != nil {
|
||||
if results.Dkim == nil {
|
||||
dkimList := []api.AuthResult{*dkimResult}
|
||||
results.Dkim = &dkimList
|
||||
} else {
|
||||
*results.Dkim = append(*results.Dkim, *dkimResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse DMARC
|
||||
if strings.HasPrefix(part, "dmarc=") {
|
||||
if results.Dmarc == nil {
|
||||
results.Dmarc = a.parseDMARCResult(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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, "-")
|
||||
}
|
||||
|
||||
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
||||
func (a *AuthenticationAnalyzer) 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
|
||||
}
|
||||
|
||||
// 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.Medium),
|
||||
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.Medium),
|
||||
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.Medium),
|
||||
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||
})
|
||||
}
|
||||
|
||||
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.Info)
|
||||
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.Critical)
|
||||
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.Medium)
|
||||
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.Low)
|
||||
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.Medium)
|
||||
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.Info)
|
||||
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.High)
|
||||
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.Medium)
|
||||
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.Info)
|
||||
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.High)
|
||||
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.Medium)
|
||||
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
|
||||
}
|
||||
|
|
@ -1,830 +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"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ContentAnalyzer analyzes email content (HTML, links, images)
|
||||
type ContentAnalyzer struct {
|
||||
Timeout time.Duration
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewContentAnalyzer creates a new content analyzer with configurable timeout
|
||||
func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second // Default timeout
|
||||
}
|
||||
return &ContentAnalyzer{
|
||||
Timeout: timeout,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Allow up to 10 redirects
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ContentResults represents content analysis results
|
||||
type ContentResults struct {
|
||||
HTMLValid bool
|
||||
HTMLErrors []string
|
||||
Links []LinkCheck
|
||||
Images []ImageCheck
|
||||
HasUnsubscribe bool
|
||||
UnsubscribeLinks []string
|
||||
TextContent string
|
||||
HTMLContent string
|
||||
TextPlainRatio float32 // Ratio of plain text to HTML consistency
|
||||
ImageTextRatio float32 // Ratio of images to text
|
||||
SuspiciousURLs []string
|
||||
ContentIssues []string
|
||||
}
|
||||
|
||||
// LinkCheck represents a link validation result
|
||||
type LinkCheck struct {
|
||||
URL string
|
||||
Valid bool
|
||||
Status int
|
||||
Error string
|
||||
IsSafe bool
|
||||
Warning string
|
||||
}
|
||||
|
||||
// ImageCheck represents an image validation result
|
||||
type ImageCheck struct {
|
||||
Src string
|
||||
HasAlt bool
|
||||
AltText string
|
||||
Valid bool
|
||||
Error string
|
||||
IsBroken bool
|
||||
}
|
||||
|
||||
// AnalyzeContent performs content analysis on email message
|
||||
func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
|
||||
results := &ContentResults{}
|
||||
|
||||
// Get HTML and text parts
|
||||
htmlParts := email.GetHTMLParts()
|
||||
textParts := email.GetTextParts()
|
||||
|
||||
// Analyze HTML parts
|
||||
if len(htmlParts) > 0 {
|
||||
for _, part := range htmlParts {
|
||||
c.analyzeHTML(part.Content, results)
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze text parts
|
||||
if len(textParts) > 0 {
|
||||
for _, part := range textParts {
|
||||
results.TextContent += part.Content
|
||||
}
|
||||
}
|
||||
|
||||
// Check plain text/HTML consistency
|
||||
if len(htmlParts) > 0 && len(textParts) > 0 {
|
||||
results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// analyzeHTML parses and analyzes HTML content
|
||||
func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) {
|
||||
results.HTMLContent = htmlContent
|
||||
|
||||
// Parse HTML
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
results.HTMLValid = false
|
||||
results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
results.HTMLValid = true
|
||||
|
||||
// Traverse HTML tree
|
||||
c.traverseHTML(doc, results)
|
||||
|
||||
// Calculate image-to-text ratio
|
||||
if results.HTMLContent != "" {
|
||||
textLength := len(c.extractTextFromHTML(htmlContent))
|
||||
imageCount := len(results.Images)
|
||||
if textLength > 0 {
|
||||
results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// traverseHTML recursively traverses HTML nodes
|
||||
func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "a":
|
||||
// Extract and validate links
|
||||
href := c.getAttr(n, "href")
|
||||
if href != "" {
|
||||
// Check for unsubscribe links
|
||||
if c.isUnsubscribeLink(href, n) {
|
||||
results.HasUnsubscribe = true
|
||||
results.UnsubscribeLinks = append(results.UnsubscribeLinks, href)
|
||||
}
|
||||
|
||||
// Validate link
|
||||
linkCheck := c.validateLink(href)
|
||||
results.Links = append(results.Links, linkCheck)
|
||||
|
||||
// Check for suspicious URLs
|
||||
if !linkCheck.IsSafe {
|
||||
results.SuspiciousURLs = append(results.SuspiciousURLs, href)
|
||||
}
|
||||
}
|
||||
|
||||
case "img":
|
||||
// Extract and validate images
|
||||
src := c.getAttr(n, "src")
|
||||
alt := c.getAttr(n, "alt")
|
||||
|
||||
imageCheck := ImageCheck{
|
||||
Src: src,
|
||||
HasAlt: alt != "",
|
||||
AltText: alt,
|
||||
Valid: src != "",
|
||||
}
|
||||
|
||||
if src == "" {
|
||||
imageCheck.Error = "Image missing src attribute"
|
||||
}
|
||||
|
||||
results.Images = append(results.Images, imageCheck)
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse children
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
c.traverseHTML(child, results)
|
||||
}
|
||||
}
|
||||
|
||||
// getAttr gets an attribute value from an HTML node
|
||||
func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key == key {
|
||||
return attr.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isUnsubscribeLink checks if a link is an unsubscribe link
|
||||
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
||||
// Check href for unsubscribe keywords
|
||||
lowerHref := strings.ToLower(href)
|
||||
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
|
||||
for _, keyword := range unsubKeywords {
|
||||
if strings.Contains(lowerHref, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check link text for unsubscribe keywords
|
||||
text := c.getNodeText(node)
|
||||
lowerText := strings.ToLower(text)
|
||||
for _, keyword := range unsubKeywords {
|
||||
if strings.Contains(lowerText, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getNodeText extracts text content from a node
|
||||
func (c *ContentAnalyzer) getNodeText(n *html.Node) string {
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
var text string
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
text += c.getNodeText(child)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// validateLink validates a URL and checks if it's accessible
|
||||
func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck {
|
||||
check := LinkCheck{
|
||||
URL: urlStr,
|
||||
IsSafe: true,
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
check.Valid = false
|
||||
check.Error = fmt.Sprintf("Invalid URL: %v", err)
|
||||
return check
|
||||
}
|
||||
|
||||
// Check URL safety
|
||||
if c.isSuspiciousURL(urlStr, parsedURL) {
|
||||
check.IsSafe = false
|
||||
check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)"
|
||||
}
|
||||
|
||||
// Only check HTTP/HTTPS links
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
check.Valid = true
|
||||
return check
|
||||
}
|
||||
|
||||
// Check if link is accessible (with timeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil)
|
||||
if err != nil {
|
||||
check.Valid = false
|
||||
check.Error = fmt.Sprintf("Failed to create request: %v", err)
|
||||
return check
|
||||
}
|
||||
|
||||
// Set a reasonable user agent
|
||||
req.Header.Set("User-Agent", "HappyDeliver/1.0 (Email Deliverability Tester)")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Don't fail on timeout/connection errors for external links
|
||||
// Just mark as warning
|
||||
check.Valid = true
|
||||
check.Status = 0
|
||||
check.Warning = fmt.Sprintf("Could not verify link: %v", err)
|
||||
return check
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
check.Status = resp.StatusCode
|
||||
check.Valid = true
|
||||
|
||||
// Check for error status codes
|
||||
if resp.StatusCode >= 400 {
|
||||
check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode)
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// isSuspiciousURL checks if a URL looks suspicious
|
||||
func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool {
|
||||
// Check for IP address instead of domain
|
||||
if c.isIPAddress(parsedURL.Host) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for URL shorteners (common ones)
|
||||
shorteners := []string{
|
||||
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
||||
"buff.ly", "is.gd", "bl.ink", "short.io",
|
||||
}
|
||||
for _, shortener := range shorteners {
|
||||
if strings.Contains(strings.ToLower(parsedURL.Host), shortener) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessive subdomains (possible obfuscation)
|
||||
parts := strings.Split(parsedURL.Host, ".")
|
||||
if len(parts) > 4 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for URL obfuscation techniques
|
||||
if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for suspicious characters in domain
|
||||
if strings.ContainsAny(parsedURL.Host, "[]()<>") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isIPAddress checks if a string is an IP address
|
||||
func (c *ContentAnalyzer) isIPAddress(host string) bool {
|
||||
// Remove port if present
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
// Simple check for IPv4
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) == 4 {
|
||||
for _, part := range parts {
|
||||
// Check if all characters are digits
|
||||
for _, ch := range part {
|
||||
if !unicode.IsDigit(ch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for IPv6 (contains colons)
|
||||
if strings.Contains(host, ":") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractTextFromHTML extracts plain text from HTML
|
||||
func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
var extract func(*html.Node)
|
||||
extract = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
text.WriteString(n.Data)
|
||||
}
|
||||
// Skip script and style tags
|
||||
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
extract(child)
|
||||
}
|
||||
}
|
||||
extract(doc)
|
||||
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// calculateTextPlainConsistency compares plain text and HTML versions
|
||||
func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 {
|
||||
// Extract text from HTML
|
||||
htmlPlainText := c.extractTextFromHTML(htmlText)
|
||||
|
||||
// Normalize both texts
|
||||
plainNorm := c.normalizeText(plainText)
|
||||
htmlNorm := c.normalizeText(htmlPlainText)
|
||||
|
||||
// Calculate similarity using simple word overlap
|
||||
plainWords := strings.Fields(plainNorm)
|
||||
htmlWords := strings.Fields(htmlNorm)
|
||||
|
||||
if len(plainWords) == 0 || len(htmlWords) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Count common words
|
||||
commonWords := 0
|
||||
plainWordSet := make(map[string]bool)
|
||||
for _, word := range plainWords {
|
||||
plainWordSet[word] = true
|
||||
}
|
||||
|
||||
for _, word := range htmlWords {
|
||||
if plainWordSet[word] {
|
||||
commonWords++
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ratio (Jaccard similarity approximation)
|
||||
maxWords := len(plainWords)
|
||||
if len(htmlWords) > maxWords {
|
||||
maxWords = len(htmlWords)
|
||||
}
|
||||
|
||||
if maxWords == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return float32(commonWords) / float32(maxWords)
|
||||
}
|
||||
|
||||
// normalizeText normalizes text for comparison
|
||||
func (c *ContentAnalyzer) normalizeText(text string) string {
|
||||
// Convert to lowercase
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// Remove extra whitespace
|
||||
text = strings.TrimSpace(text)
|
||||
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// GenerateContentChecks generates check results for content analysis
|
||||
func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if results == nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
// HTML validity check
|
||||
checks = append(checks, c.generateHTMLValidityCheck(results))
|
||||
|
||||
// Link checks
|
||||
checks = append(checks, c.generateLinkChecks(results)...)
|
||||
|
||||
// Image checks
|
||||
checks = append(checks, c.generateImageChecks(results)...)
|
||||
|
||||
// Unsubscribe link check
|
||||
checks = append(checks, c.generateUnsubscribeCheck(results))
|
||||
|
||||
// Text/HTML consistency check
|
||||
if results.TextContent != "" && results.HTMLContent != "" {
|
||||
checks = append(checks, c.generateTextConsistencyCheck(results))
|
||||
}
|
||||
|
||||
// Image-to-text ratio check
|
||||
if len(results.Images) > 0 && results.HTMLContent != "" {
|
||||
checks = append(checks, c.generateImageRatioCheck(results))
|
||||
}
|
||||
|
||||
// Suspicious URLs check
|
||||
if len(results.SuspiciousURLs) > 0 {
|
||||
checks = append(checks, c.generateSuspiciousURLCheck(results))
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateHTMLValidityCheck creates a check for HTML validity
|
||||
func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "HTML Structure",
|
||||
}
|
||||
|
||||
if !results.HTMLValid {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Message = "HTML structure is invalid"
|
||||
if len(results.HTMLErrors) > 0 {
|
||||
details := strings.Join(results.HTMLErrors, "; ")
|
||||
check.Details = &details
|
||||
}
|
||||
check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.2
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Message = "HTML structure is valid"
|
||||
check.Advice = api.PtrTo("Your HTML is well-formed")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateLinkChecks creates checks for links
|
||||
func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if len(results.Links) == 0 {
|
||||
return checks
|
||||
}
|
||||
|
||||
// Count broken links
|
||||
brokenLinks := 0
|
||||
warningLinks := 0
|
||||
for _, link := range results.Links {
|
||||
if link.Status >= 400 {
|
||||
brokenLinks++
|
||||
} else if link.Warning != "" {
|
||||
warningLinks++
|
||||
}
|
||||
}
|
||||
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Links",
|
||||
}
|
||||
|
||||
if brokenLinks > 0 {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.High)
|
||||
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
|
||||
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
|
||||
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
|
||||
check.Details = &details
|
||||
} else if warningLinks > 0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.3
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
|
||||
check.Advice = api.PtrTo("Review links that could not be verified")
|
||||
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.4
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
|
||||
check.Advice = api.PtrTo("Your links are working properly")
|
||||
}
|
||||
|
||||
checks = append(checks, check)
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateImageChecks creates checks for images
|
||||
func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if len(results.Images) == 0 {
|
||||
return checks
|
||||
}
|
||||
|
||||
// Count images without alt text
|
||||
noAltCount := 0
|
||||
for _, img := range results.Images {
|
||||
if !img.HasAlt {
|
||||
noAltCount++
|
||||
}
|
||||
}
|
||||
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Image Alt Attributes",
|
||||
}
|
||||
|
||||
if noAltCount == len(results.Images) {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Message = "No images have alt attributes"
|
||||
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
|
||||
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||
check.Details = &details
|
||||
} else if noAltCount > 0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.2
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
|
||||
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
|
||||
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.3
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Message = "All images have alt attributes"
|
||||
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
|
||||
}
|
||||
|
||||
checks = append(checks, check)
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateUnsubscribeCheck creates a check for unsubscribe links
|
||||
func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Unsubscribe Link",
|
||||
}
|
||||
|
||||
if !results.HasUnsubscribe {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Message = "No unsubscribe link found"
|
||||
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.3
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
|
||||
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateTextConsistencyCheck creates a check for text/HTML consistency
|
||||
func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Plain Text Consistency",
|
||||
}
|
||||
|
||||
consistency := results.TextPlainRatio
|
||||
|
||||
if consistency < 0.3 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Message = "Plain text and HTML versions differ significantly"
|
||||
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
|
||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.3
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Message = "Plain text and HTML versions are consistent"
|
||||
check.Advice = api.PtrTo("Your multipart email is well-structured")
|
||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateImageRatioCheck creates a check for image-to-text ratio
|
||||
func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Image-to-Text Ratio",
|
||||
}
|
||||
|
||||
ratio := results.ImageTextRatio
|
||||
|
||||
// Flag if more than 1 image per 100 characters (very image-heavy)
|
||||
if ratio > 10.0 {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Message = "Email is excessively image-heavy"
|
||||
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
|
||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||
check.Details = &details
|
||||
} else if ratio > 5.0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.2
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Message = "Email has high image-to-text ratio"
|
||||
check.Advice = api.PtrTo("Consider adding more text content relative to images")
|
||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.3
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Message = "Image-to-text ratio is reasonable"
|
||||
check.Advice = api.PtrTo("Your content has a good balance of images and text")
|
||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateSuspiciousURLCheck creates a check for suspicious URLs
|
||||
func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Suspicious URLs",
|
||||
}
|
||||
|
||||
count := len(results.SuspiciousURLs)
|
||||
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
|
||||
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
|
||||
|
||||
if count <= 3 {
|
||||
details := strings.Join(results.SuspiciousURLs, ", ")
|
||||
check.Details = &details
|
||||
} else {
|
||||
details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// GetContentScore calculates the content score (0-2 points)
|
||||
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 {
|
||||
if results == nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var score float32 = 0.0
|
||||
|
||||
// HTML validity (0.2 points)
|
||||
if results.HTMLValid {
|
||||
score += 0.2
|
||||
}
|
||||
|
||||
// Links (0.4 points)
|
||||
if len(results.Links) > 0 {
|
||||
brokenLinks := 0
|
||||
for _, link := range results.Links {
|
||||
if link.Status >= 400 {
|
||||
brokenLinks++
|
||||
}
|
||||
}
|
||||
if brokenLinks == 0 {
|
||||
score += 0.4
|
||||
}
|
||||
} else {
|
||||
// No links is neutral, give partial score
|
||||
score += 0.2
|
||||
}
|
||||
|
||||
// Images (0.3 points)
|
||||
if len(results.Images) > 0 {
|
||||
noAltCount := 0
|
||||
for _, img := range results.Images {
|
||||
if !img.HasAlt {
|
||||
noAltCount++
|
||||
}
|
||||
}
|
||||
if noAltCount == 0 {
|
||||
score += 0.3
|
||||
} else if noAltCount < len(results.Images) {
|
||||
score += 0.15
|
||||
}
|
||||
} else {
|
||||
// No images is neutral
|
||||
score += 0.15
|
||||
}
|
||||
|
||||
// Unsubscribe link (0.3 points)
|
||||
if results.HasUnsubscribe {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// Text consistency (0.3 points)
|
||||
if results.TextPlainRatio >= 0.3 {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// Image ratio (0.3 points)
|
||||
if results.ImageTextRatio <= 5.0 {
|
||||
score += 0.3
|
||||
} else if results.ImageTextRatio <= 10.0 {
|
||||
score += 0.15
|
||||
}
|
||||
|
||||
// Penalize suspicious URLs (deduct up to 0.5 points)
|
||||
if len(results.SuspiciousURLs) > 0 {
|
||||
penalty := float32(len(results.SuspiciousURLs)) * 0.1
|
||||
if penalty > 0.5 {
|
||||
penalty = 0.5
|
||||
}
|
||||
score -= penalty
|
||||
}
|
||||
|
||||
// Ensure score is between 0 and 2
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 2.0 {
|
||||
score = 2.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
|
@ -1,566 +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"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// DNSAnalyzer analyzes DNS records for email domains
|
||||
type DNSAnalyzer struct {
|
||||
Timeout time.Duration
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
|
||||
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second // Default timeout
|
||||
}
|
||||
return &DNSAnalyzer{
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// AnalyzeDNS performs DNS validation for the email's domain
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
|
||||
// Extract domain from From address
|
||||
domain := d.extractDomain(email)
|
||||
if domain == "" {
|
||||
return &DNSResults{
|
||||
Errors: []string{"Unable to extract domain from email"},
|
||||
}
|
||||
}
|
||||
|
||||
results := &DNSResults{
|
||||
Domain: domain,
|
||||
}
|
||||
|
||||
// Check MX records
|
||||
results.MXRecords = d.checkMXRecords(domain)
|
||||
|
||||
// Check SPF record
|
||||
results.SPFRecord = d.checkSPFRecord(domain)
|
||||
|
||||
// Check DKIM records (from authentication results)
|
||||
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 {
|
||||
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check DMARC record
|
||||
results.DMARCRecord = d.checkDMARCRecord(domain)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
||||
if err != nil {
|
||||
return []MXRecord{
|
||||
{
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if len(mxRecords) == 0 {
|
||||
return []MXRecord{
|
||||
{
|
||||
Valid: false,
|
||||
Error: "No MX records found",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var results []MXRecord
|
||||
for _, mx := range mxRecords {
|
||||
results = append(results, MXRecord{
|
||||
Host: mx.Host,
|
||||
Priority: mx.Pref,
|
||||
Valid: true,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
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.Critical)
|
||||
|
||||
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.Info)
|
||||
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.High)
|
||||
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.Medium)
|
||||
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.Info)
|
||||
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.High)
|
||||
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.Info)
|
||||
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.High)
|
||||
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.Info)
|
||||
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
|
||||
}
|
||||
|
|
@ -1,633 +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 (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestNewDNSAnalyzer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
expectedTimeout time.Duration
|
||||
}{
|
||||
{
|
||||
name: "Default timeout",
|
||||
timeout: 0,
|
||||
expectedTimeout: 10 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "Custom timeout",
|
||||
timeout: 5 * time.Second,
|
||||
expectedTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
analyzer := NewDNSAnalyzer(tt.timeout)
|
||||
if analyzer.Timeout != tt.expectedTimeout {
|
||||
t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout)
|
||||
}
|
||||
if analyzer.resolver == nil {
|
||||
t.Error("Resolver should not be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,408 +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"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// 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
|
||||
var DefaultRBLs = []string{
|
||||
"zen.spamhaus.org", // Spamhaus combined list
|
||||
"bl.spamcop.net", // SpamCop
|
||||
"dnsbl.sorbs.net", // SORBS
|
||||
"b.barracudacentral.org", // Barracuda
|
||||
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||
}
|
||||
|
||||
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second // Default timeout
|
||||
}
|
||||
if len(rbls) == 0 {
|
||||
rbls = DefaultRBLs
|
||||
}
|
||||
return &RBLChecker{
|
||||
Timeout: timeout,
|
||||
RBLs: rbls,
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RBLResults represents the results of RBL checks
|
||||
type RBLResults struct {
|
||||
Checks []RBLCheck
|
||||
IPsChecked []string
|
||||
ListedCount int
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
results.IPsChecked = ips
|
||||
|
||||
// Check each IP against all RBLs
|
||||
for _, ip := range ips {
|
||||
for _, rbl := range r.RBLs {
|
||||
check := r.checkIP(ip, rbl)
|
||||
results.Checks = append(results.Checks, check)
|
||||
if check.Listed {
|
||||
results.ListedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// extractIPs extracts IP addresses from Received headers
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ips = append(ips, matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips
|
||||
}
|
||||
|
||||
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||
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
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 = "Failed to reverse IP address"
|
||||
return check
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// 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.Listed = true
|
||||
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// reverseIP reverses an IPv4 address for DNSBL queries
|
||||
// Example: 192.0.2.1 -> 1.2.0.192
|
||||
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])
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// No IPs to check, give benefit of doubt
|
||||
return 2.0
|
||||
}
|
||||
|
||||
listedCount := results.ListedCount
|
||||
|
||||
if listedCount == 0 {
|
||||
return 2.0
|
||||
} else if listedCount == 1 {
|
||||
return 1.0
|
||||
} else if listedCount <= 3 {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 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.Low),
|
||||
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.Info)
|
||||
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.Medium)
|
||||
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.High)
|
||||
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.Critical)
|
||||
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.Critical)
|
||||
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.High)
|
||||
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.High)
|
||||
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 _, check := range results.Checks {
|
||||
if check.Listed && !seenIPs[check.IP] {
|
||||
listedIPs = append(listedIPs, check.IP)
|
||||
seenIPs[check.IP] = true
|
||||
}
|
||||
}
|
||||
|
||||
return listedIPs
|
||||
}
|
||||
|
||||
// GetRBLsForIP returns all RBLs that list a specific IP
|
||||
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
||||
var rbls []string
|
||||
|
||||
for _, check := range results.Checks {
|
||||
if check.IP == ip && check.Listed {
|
||||
rbls = append(rbls, check.RBL)
|
||||
}
|
||||
}
|
||||
|
||||
return rbls
|
||||
}
|
||||
|
|
@ -1,348 +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 (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ReportGenerator generates comprehensive deliverability reports
|
||||
type ReportGenerator struct {
|
||||
authAnalyzer *AuthenticationAnalyzer
|
||||
spamAnalyzer *SpamAssassinAnalyzer
|
||||
dnsAnalyzer *DNSAnalyzer
|
||||
rblChecker *RBLChecker
|
||||
contentAnalyzer *ContentAnalyzer
|
||||
scorer *DeliverabilityScorer
|
||||
}
|
||||
|
||||
// NewReportGenerator creates a new report generator
|
||||
func NewReportGenerator(
|
||||
dnsTimeout time.Duration,
|
||||
httpTimeout time.Duration,
|
||||
rbls []string,
|
||||
) *ReportGenerator {
|
||||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls),
|
||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||
scorer: NewDeliverabilityScorer(),
|
||||
}
|
||||
}
|
||||
|
||||
// AnalysisResults contains all intermediate analysis results
|
||||
type AnalysisResults struct {
|
||||
Email *EmailMessage
|
||||
Authentication *api.AuthenticationResults
|
||||
SpamAssassin *SpamAssassinResult
|
||||
DNS *DNSResults
|
||||
RBL *RBLResults
|
||||
Content *ContentResults
|
||||
Score *ScoringResult
|
||||
}
|
||||
|
||||
// AnalyzeEmail performs complete email analysis
|
||||
func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||
results := &AnalysisResults{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
// Run all analyzers
|
||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(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
|
||||
}
|
||||
|
||||
// GenerateReport creates a complete API report from analysis results
|
||||
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
|
||||
reportID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
report := &api.Report{
|
||||
Id: reportID,
|
||||
TestId: testID,
|
||||
Score: results.Score.OverallScore,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
// Build score summary
|
||||
report.Summary = &api.ScoreSummary{
|
||||
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 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 {
|
||||
dnsRecords := r.buildDNSRecords(results.DNS)
|
||||
if len(dnsRecords) > 0 {
|
||||
report.DnsRecords = &dnsRecords
|
||||
}
|
||||
}
|
||||
|
||||
// Add blacklist checks
|
||||
if results.RBL != nil && len(results.RBL.Checks) > 0 {
|
||||
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 raw headers
|
||||
if results.Email != nil && results.Email.RawHeaders != "" {
|
||||
report.RawHeaders = &results.Email.RawHeaders
|
||||
}
|
||||
|
||||
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 {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := email.RawHeaders
|
||||
if email.RawBody != "" {
|
||||
raw += "\n" + email.RawBody
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,501 +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 (
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNewReportGenerator(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
if gen == nil {
|
||||
t.Fatal("Expected report generator, got nil")
|
||||
}
|
||||
|
||||
if gen.authAnalyzer == nil {
|
||||
t.Error("authAnalyzer should not be nil")
|
||||
}
|
||||
if gen.spamAnalyzer == nil {
|
||||
t.Error("spamAnalyzer should not be nil")
|
||||
}
|
||||
if gen.dnsAnalyzer == nil {
|
||||
t.Error("dnsAnalyzer should not be nil")
|
||||
}
|
||||
if gen.rblChecker == nil {
|
||||
t.Error("rblChecker should not be nil")
|
||||
}
|
||||
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)
|
||||
|
||||
email := createTestEmail()
|
||||
|
||||
results := gen.AnalyzeEmail(email)
|
||||
|
||||
if results == nil {
|
||||
t.Fatal("Expected analysis results, got nil")
|
||||
}
|
||||
|
||||
if results.Email == nil {
|
||||
t.Error("Email should not be nil")
|
||||
}
|
||||
|
||||
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)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmail()
|
||||
results := gen.AnalyzeEmail(email)
|
||||
|
||||
report := gen.GenerateReport(testID, results)
|
||||
|
||||
if report == nil {
|
||||
t.Fatal("Expected report, got nil")
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if report.Id == uuid.Nil {
|
||||
t.Error("Report ID should not be empty")
|
||||
}
|
||||
|
||||
if report.TestId != testID {
|
||||
t.Errorf("TestId = %s, want %s", report.TestId, testID)
|
||||
}
|
||||
|
||||
if report.Score < 0 || report.Score > 10 {
|
||||
t.Errorf("Score %v is out of bounds", report.Score)
|
||||
}
|
||||
|
||||
if report.Summary == nil {
|
||||
t.Error("Summary should not be nil")
|
||||
}
|
||||
|
||||
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 > 3 {
|
||||
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
|
||||
}
|
||||
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 > 2 {
|
||||
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
|
||||
}
|
||||
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 > 1 {
|
||||
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
results := gen.AnalyzeEmail(email)
|
||||
|
||||
report := gen.GenerateReport(testID, results)
|
||||
|
||||
if report.Spamassassin == nil {
|
||||
t.Error("SpamAssassin result should not be nil")
|
||||
}
|
||||
|
||||
if report.Spamassassin != nil {
|
||||
if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 {
|
||||
t.Error("SpamAssassin scores should be set")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Nil email",
|
||||
email: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Email with headers only",
|
||||
email: &EmailMessage{
|
||||
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n",
|
||||
RawBody: "",
|
||||
},
|
||||
expected: "From: sender@example.com\nTo: recipient@example.com\n",
|
||||
},
|
||||
{
|
||||
name: "Email with headers and body",
|
||||
email: &EmailMessage{
|
||||
RawHeaders: "From: sender@example.com\n",
|
||||
RawBody: "This is the email body",
|
||||
},
|
||||
expected: "From: sender@example.com\n\nThis is the email body",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := gen.GenerateRawEmail(tt.email)
|
||||
if raw != tt.expected {
|
||||
t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
header := make(mail.Header)
|
||||
header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{"<test123@example.com>"}
|
||||
|
||||
return &EmailMessage{
|
||||
Header: header,
|
||||
From: &mail.Address{Address: "sender@example.com"},
|
||||
To: []*mail.Address{{Address: "recipient@example.com"}},
|
||||
Subject: "Test Email",
|
||||
MessageID: "<test123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{
|
||||
{
|
||||
ContentType: "text/plain",
|
||||
Content: "This is a test email",
|
||||
IsText: true,
|
||||
},
|
||||
},
|
||||
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: <test123@example.com>\n",
|
||||
RawBody: "This is a test email",
|
||||
}
|
||||
}
|
||||
|
||||
func createTestEmailWithSpamAssassin() *EmailMessage {
|
||||
email := createTestEmail()
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"}
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"}
|
||||
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,506 +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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// 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
|
||||
authAnalyzer := NewAuthenticationAnalyzer()
|
||||
result.AuthScore = authAnalyzer.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 >= 9.0:
|
||||
return "Excellent"
|
||||
case score >= 7.0:
|
||||
return "Good"
|
||||
case score >= 5.0:
|
||||
return "Fair"
|
||||
case score >= 3.0:
|
||||
return "Poor"
|
||||
default:
|
||||
return "Critical"
|
||||
}
|
||||
}
|
||||
|
||||
// getCategoryStatus determines status for a category
|
||||
func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
|
||||
percentage := (score / maxScore) * 100
|
||||
|
||||
switch {
|
||||
case percentage >= 80.0:
|
||||
return "Pass"
|
||||
case percentage >= 50.0:
|
||||
return "Warn"
|
||||
default:
|
||||
return "Fail"
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// generateRequiredHeadersCheck checks for required RFC 5322 headers
|
||||
func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Headers,
|
||||
Name: "Required Headers",
|
||||
}
|
||||
|
||||
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.Info)
|
||||
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.Critical)
|
||||
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.Info)
|
||||
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.Low)
|
||||
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.Medium)
|
||||
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.High)
|
||||
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.Medium)
|
||||
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.Info)
|
||||
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.Low)
|
||||
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.Info)
|
||||
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()
|
||||
}
|
||||
|
|
@ -1,762 +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 (
|
||||
"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
|
||||
}
|
||||
|
|
@ -1,340 +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"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
||||
type SpamAssassinAnalyzer struct{}
|
||||
|
||||
// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer
|
||||
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) *SpamAssassinResult {
|
||||
headers := email.GetSpamAssassinHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &SpamAssassinResult{
|
||||
TestDetails: make(map[string]SpamTestDetail),
|
||||
}
|
||||
|
||||
// Parse X-Spam-Status header
|
||||
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 = score
|
||||
}
|
||||
}
|
||||
|
||||
// Parse X-Spam-Flag header (as fallback)
|
||||
if flagHeader, ok := headers["X-Spam-Flag"]; ok {
|
||||
result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES"
|
||||
}
|
||||
|
||||
// Parse X-Spam-Report header for detailed test results
|
||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||
result.RawReport = reportHeader
|
||||
a.parseSpamReport(reportHeader, result)
|
||||
}
|
||||
|
||||
// Parse X-Spam-Checker-Version
|
||||
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
||||
result.Version = strings.TrimSpace(versionHeader)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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 *SpamAssassinResult) {
|
||||
// Check if spam (first word)
|
||||
parts := strings.SplitN(header, ",", 2)
|
||||
if len(parts) > 0 {
|
||||
firstPart := strings.TrimSpace(parts[0])
|
||||
result.IsSpam = strings.EqualFold(firstPart, "yes")
|
||||
}
|
||||
|
||||
// Extract score
|
||||
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 = score
|
||||
}
|
||||
}
|
||||
|
||||
// Extract required score
|
||||
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 = required
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tests
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpamReport parses the X-Spam-Report header to extract test details
|
||||
// Format varies, but typically:
|
||||
// * 1.5 TEST_NAME Description of test
|
||||
// * 0.0 TEST_NAME2 Description
|
||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
|
||||
// Split by lines
|
||||
lines := strings.Split(report, "\n")
|
||||
|
||||
// Regex to match test lines: * score TEST_NAME Description
|
||||
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := testRe.FindStringSubmatch(line)
|
||||
if len(matches) > 3 {
|
||||
testName := matches[2]
|
||||
score, _ := strconv.ParseFloat(matches[1], 64)
|
||||
description := strings.TrimSpace(matches[3])
|
||||
|
||||
detail := SpamTestDetail{
|
||||
Name: testName,
|
||||
Score: score,
|
||||
Description: description,
|
||||
}
|
||||
result.TestDetails[testName] = detail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 0.0
|
||||
}
|
||||
|
||||
score := result.Score
|
||||
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.Medium),
|
||||
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.Info)
|
||||
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.Info)
|
||||
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.Medium)
|
||||
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.High)
|
||||
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.Critical)
|
||||
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.High)
|
||||
} else {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
}
|
||||
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.Info)
|
||||
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
|
||||
}
|
||||
383
internal/api/handlers.go
Normal file
383
internal/api/handlers.go
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
// 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 api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
|
||||
"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 {
|
||||
return &APIHandler{
|
||||
storage: store,
|
||||
config: cfg,
|
||||
analyzer: analyzer,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTest creates a new deliverability test
|
||||
// (POST /test)
|
||||
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
|
||||
email := fmt.Sprintf("%s%s@%s",
|
||||
h.config.Email.TestAddressPrefix,
|
||||
base32ID,
|
||||
h.config.Email.Domain,
|
||||
)
|
||||
|
||||
// Return response
|
||||
c.JSON(http.StatusCreated, TestResponse{
|
||||
Id: base32ID,
|
||||
Email: openapi_types.Email(email),
|
||||
Status: TestResponseStatusPending,
|
||||
Message: stringPtr("Send your test email to the given address"),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Check if a report exists for this test ID
|
||||
reportExists, err := h.storage.ReportExists(testUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
Message: "Failed to check test status",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine status based on report existence
|
||||
var apiStatus TestStatus
|
||||
if reportExists {
|
||||
apiStatus = TestStatusAnalyzed
|
||||
} else {
|
||||
apiStatus = TestStatusPending
|
||||
}
|
||||
|
||||
// Generate test email address using Base32-encoded UUID
|
||||
email := fmt.Sprintf("%s%s@%s",
|
||||
h.config.Email.TestAddressPrefix,
|
||||
id,
|
||||
h.config.Email.Domain,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, Test{
|
||||
Id: id,
|
||||
Email: openapi_types.Email(email),
|
||||
Status: apiStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, Error{
|
||||
Error: "not_found",
|
||||
Message: "Report not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
Message: "Failed to retrieve report",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return raw JSON directly
|
||||
c.Data(http.StatusOK, "application/json", reportJSON)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 raw email",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
// Calculate uptime
|
||||
uptime := int(time.Since(h.startTime).Seconds())
|
||||
|
||||
// Check database connectivity by trying to check if a report exists
|
||||
dbStatus := StatusComponentsDatabaseUp
|
||||
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
|
||||
dbStatus = StatusComponentsDatabaseDown
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
overallStatus := Healthy
|
||||
if dbStatus == StatusComponentsDatabaseDown {
|
||||
overallStatus = Unhealthy
|
||||
}
|
||||
|
||||
mtaStatus := StatusComponentsMtaUp
|
||||
c.JSON(http.StatusOK, Status{
|
||||
Status: overallStatus,
|
||||
Version: version.Version,
|
||||
Components: &struct {
|
||||
Database *StatusComponentsDatabase `json:"database,omitempty"`
|
||||
Mta *StatusComponentsMta `json:"mta,omitempty"`
|
||||
}{
|
||||
Database: &dbStatus,
|
||||
Mta: &mtaStatus,
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
package api
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// PtrTo returns a pointer to the provided value
|
||||
func PtrTo[T any](v T) *T {
|
||||
return &v
|
||||
|
|
|
|||
108
internal/app/cleanup.go
Normal file
108
internal/app/cleanup.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
// How often to run the cleanup check
|
||||
cleanupInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
// CleanupService handles periodic cleanup of old reports
|
||||
type CleanupService struct {
|
||||
store storage.Storage
|
||||
retention time.Duration
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewCleanupService creates a new cleanup service
|
||||
func NewCleanupService(store storage.Storage, retention time.Duration) *CleanupService {
|
||||
return &CleanupService{
|
||||
store: store,
|
||||
retention: retention,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the cleanup service in a background goroutine
|
||||
func (s *CleanupService) Start(ctx context.Context) {
|
||||
if s.retention <= 0 {
|
||||
log.Println("Report retention is disabled (keeping reports forever)")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Starting cleanup service: will delete reports older than %s", s.retention)
|
||||
|
||||
// Run cleanup immediately on startup
|
||||
s.runCleanup()
|
||||
|
||||
// Then run periodically
|
||||
s.ticker = time.NewTicker(cleanupInterval)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-s.ticker.C:
|
||||
s.runCleanup()
|
||||
case <-ctx.Done():
|
||||
s.Stop()
|
||||
return
|
||||
case <-s.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop stops the cleanup service
|
||||
func (s *CleanupService) Stop() {
|
||||
if s.ticker != nil {
|
||||
s.ticker.Stop()
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
// runCleanup performs the actual cleanup operation
|
||||
func (s *CleanupService) runCleanup() {
|
||||
cutoffTime := time.Now().Add(-s.retention)
|
||||
log.Printf("Running cleanup: deleting reports older than %s", cutoffTime.Format(time.RFC3339))
|
||||
|
||||
deleted, err := s.store.DeleteOldReports(cutoffTime)
|
||||
if err != nil {
|
||||
log.Printf("Error during cleanup: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
log.Printf("Cleanup completed: deleted %d old report(s)", deleted)
|
||||
} else {
|
||||
log.Printf("Cleanup completed: no old reports to delete")
|
||||
}
|
||||
}
|
||||
634
internal/app/cli_analyzer.go
Normal file
634
internal/app/cli_analyzer.go
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
// 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"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/pkg/analyzer"
|
||||
)
|
||||
|
||||
// RunAnalyzer runs the standalone email analyzer (from stdin)
|
||||
func RunAnalyzer(cfg *config.Config, args []string, reader io.Reader, writer io.Writer) error {
|
||||
// Parse command-line flags
|
||||
fs := flag.NewFlagSet("analyze", flag.ExitOnError)
|
||||
jsonOutput := fs.Bool("json", false, "Output results as JSON")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Email analyzer ready, reading from stdin...")
|
||||
|
||||
// Read email from stdin
|
||||
emailData, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read email from stdin: %w", err)
|
||||
}
|
||||
|
||||
// Create analyzer with configuration
|
||||
emailAnalyzer := analyzer.NewEmailAnalyzer(cfg)
|
||||
|
||||
// Analyze the email (using a dummy test ID for standalone mode)
|
||||
result, err := emailAnalyzer.AnalyzeEmailBytes(emailData, uuid.New())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Analyzing email from: %s", result.Email.From)
|
||||
|
||||
// Output results
|
||||
if *jsonOutput {
|
||||
return outputJSON(result, writer)
|
||||
}
|
||||
return outputHumanReadable(result, emailAnalyzer, writer)
|
||||
}
|
||||
|
||||
// outputJSON outputs the report as JSON
|
||||
func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
|
||||
reportJSON, err := json.MarshalIndent(result.Report, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
fmt.Fprintln(writer, string(reportJSON))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
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))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// DNS Results
|
||||
if report.DnsResults != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "DNS CONFIGURATION")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
156
internal/app/cli_backup.go
Normal file
156
internal/app/cli_backup.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
)
|
||||
|
||||
// BackupData represents the structure of a backup file
|
||||
type BackupData struct {
|
||||
Version string `json:"version"`
|
||||
Reports []storage.Report `json:"reports"`
|
||||
}
|
||||
|
||||
// RunBackup exports the database to stdout as JSON
|
||||
func RunBackup(cfg *config.Config) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
|
||||
|
||||
// Get all reports from the database
|
||||
reports, err := storage.GetAllReports(store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve reports: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports))
|
||||
|
||||
// Create backup data structure
|
||||
backup := BackupData{
|
||||
Version: "1.0",
|
||||
Reports: reports,
|
||||
}
|
||||
|
||||
// Encode to JSON and write to stdout
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(backup); err != nil {
|
||||
return fmt.Errorf("failed to encode backup data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunRestore imports the database from a JSON file or stdin
|
||||
func RunRestore(cfg *config.Config, inputPath string) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine input source
|
||||
var reader io.Reader
|
||||
if inputPath == "" || inputPath == "-" {
|
||||
fmt.Fprintln(os.Stderr, "Reading backup from stdin...")
|
||||
reader = os.Stdin
|
||||
} else {
|
||||
inFile, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open backup file: %w", err)
|
||||
}
|
||||
defer inFile.Close()
|
||||
fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath)
|
||||
reader = inFile
|
||||
}
|
||||
|
||||
// Decode JSON
|
||||
var backup BackupData
|
||||
decoder := json.NewDecoder(reader)
|
||||
if err := decoder.Decode(&backup); err != nil {
|
||||
if err == io.EOF {
|
||||
return fmt.Errorf("backup file is empty or corrupted")
|
||||
}
|
||||
return fmt.Errorf("failed to decode backup data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version)
|
||||
fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports))
|
||||
|
||||
// Initialize storage
|
||||
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
|
||||
|
||||
// Restore reports
|
||||
restored, skipped, failed := 0, 0, 0
|
||||
for _, report := range backup.Reports {
|
||||
// Check if report already exists
|
||||
exists, err := store.ReportExists(report.TestID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if exists {
|
||||
fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create the report
|
||||
_, err = storage.CreateReportFromBackup(store, &report)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("restore completed with %d failures", failed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
117
internal/app/server.go
Normal file
117
internal/app/server.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// 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 (
|
||||
"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"
|
||||
)
|
||||
|
||||
// RunServer starts the API server and LMTP server
|
||||
func RunServer(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 err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
log.Printf("Connected to %s database", cfg.Database.Type)
|
||||
|
||||
// Start cleanup service for old reports
|
||||
ctx := context.Background()
|
||||
cleanupSvc := NewCleanupService(store, cfg.ReportRetention)
|
||||
cleanupSvc.Start(ctx)
|
||||
defer cleanupSvc.Stop()
|
||||
|
||||
// Start LMTP server in background
|
||||
go func() {
|
||||
if err := lmtp.StartServer(cfg.Email.LMTPAddr, store, cfg); err != nil {
|
||||
log.Fatalf("Failed to start LMTP server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create analyzer adapter for API
|
||||
analyzerAdapter := analyzer.NewAPIAdapter(cfg)
|
||||
|
||||
// Create API handler
|
||||
handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
|
||||
|
||||
// Set up Gin router
|
||||
if os.Getenv("GIN_MODE") == "" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
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
|
||||
api.RegisterHandlers(apiGroup, handler)
|
||||
web.DeclareRoutes(cfg, router)
|
||||
|
||||
// Start API server
|
||||
log.Printf("Starting API server on %s", cfg.Bind)
|
||||
log.Printf("Test email domain: %s", cfg.Email.Domain)
|
||||
|
||||
if err := router.Run(cfg.Bind); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
54
internal/config/cli.go
Normal file
54
internal/config/cli.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// 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 config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
)
|
||||
|
||||
// declareFlags registers flags for the structure Options.
|
||||
func declareFlags(o *Config) {
|
||||
flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets")
|
||||
flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket")
|
||||
flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres")
|
||||
flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path")
|
||||
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
||||
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
||||
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
||||
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
|
||||
}
|
||||
|
||||
// parseCLI parse the flags and treats extra args as configuration filename.
|
||||
func parseCLI(o *Config) error {
|
||||
flag.Parse()
|
||||
|
||||
return nil
|
||||
}
|
||||
189
internal/config/config.go
Normal file
189
internal/config/config.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// 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 config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
DevProxy string
|
||||
Bind string
|
||||
Database DatabaseConfig
|
||||
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
|
||||
type DatabaseConfig struct {
|
||||
Type string
|
||||
DSN string
|
||||
}
|
||||
|
||||
// EmailConfig contains email domain and routing settings
|
||||
type EmailConfig struct {
|
||||
Domain string
|
||||
TestAddressPrefix string
|
||||
LMTPAddr string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DefaultConfig returns a configuration with sensible defaults
|
||||
func DefaultConfig() *Config {
|
||||
return &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",
|
||||
},
|
||||
Email: EmailConfig{
|
||||
Domain: "happydeliver.local",
|
||||
TestAddressPrefix: "test-",
|
||||
LMTPAddr: "127.0.0.1:2525",
|
||||
},
|
||||
Analysis: AnalysisConfig{
|
||||
DNSTimeout: 5 * time.Second,
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
RBLs: []string{},
|
||||
DNSWLs: []string{},
|
||||
CheckAllIPs: false, // By default, only check the first IP
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConsolidateConfig fills an Options struct by reading configuration from
|
||||
// config files, environment, then command line.
|
||||
//
|
||||
// Should be called only one time.
|
||||
func ConsolidateConfig() (opts *Config, err error) {
|
||||
// Define defaults options
|
||||
opts = DefaultConfig()
|
||||
|
||||
declareFlags(opts)
|
||||
|
||||
// Establish a list of possible configuration file locations
|
||||
configLocations := []string{
|
||||
"happydeliver.conf",
|
||||
}
|
||||
|
||||
if home, err := os.UserConfigDir(); err == nil {
|
||||
configLocations = append(
|
||||
configLocations,
|
||||
path.Join(home, "happydeliver", "happydeliver.conf"),
|
||||
path.Join(home, "happydomain", "happydeliver.conf"),
|
||||
)
|
||||
}
|
||||
|
||||
configLocations = append(configLocations, path.Join("etc", "happydeliver.conf"))
|
||||
|
||||
// If config file exists, read configuration from it
|
||||
for _, filename := range configLocations {
|
||||
if _, e := os.Stat(filename); !os.IsNotExist(e) && !os.IsPermission(e) {
|
||||
log.Printf("Loading configuration from %s\n", filename)
|
||||
err = parseFile(opts, filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Then, overwrite that by what is present in the environment
|
||||
err = parseEnvironmentVariables(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Finaly, command line takes precedence
|
||||
err = parseCLI(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
if c.Email.Domain == "" {
|
||||
return fmt.Errorf("email domain cannot be empty")
|
||||
}
|
||||
|
||||
if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil {
|
||||
return fmt.Errorf("invalid email domain: %w", err)
|
||||
}
|
||||
|
||||
if c.Database.Type != "sqlite" && c.Database.Type != "postgres" {
|
||||
return fmt.Errorf("unsupported database type: %s", c.Database.Type)
|
||||
}
|
||||
|
||||
if c.Database.DSN == "" {
|
||||
return fmt.Errorf("database DSN cannot be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLine treats a config line and place the read value in the variable
|
||||
// declared to the corresponding flag.
|
||||
func parseLine(o *Config, line string) (err error) {
|
||||
fields := strings.SplitN(line, "=", 2)
|
||||
orig_key := strings.TrimSpace(fields[0])
|
||||
value := strings.TrimSpace(fields[1])
|
||||
|
||||
if len(value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_")
|
||||
key = strings.Replace(key, "_", "-", -1)
|
||||
key = strings.ToLower(key)
|
||||
|
||||
err = flag.Set(key, value)
|
||||
|
||||
return
|
||||
}
|
||||
68
internal/config/custom.go
Normal file
68
internal/config/custom.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// 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 config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StringArray struct {
|
||||
Array *[]string
|
||||
}
|
||||
|
||||
func (i *StringArray) String() string {
|
||||
if i.Array == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", *i.Array)
|
||||
}
|
||||
|
||||
func (i *StringArray) Set(value string) error {
|
||||
*i.Array = append(*i.Array, strings.Split(value, ",")...)
|
||||
|
||||
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
|
||||
}
|
||||
42
internal/config/env.go
Normal file
42
internal/config/env.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// 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 config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseEnvironmentVariables analyzes all the environment variables to find
|
||||
// each one starting by HAPPYDELIVER_
|
||||
func parseEnvironmentVariables(o *Config) (err error) {
|
||||
for _, line := range os.Environ() {
|
||||
if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") {
|
||||
err := parseLine(o, line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error in environment (%q): %w", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
54
internal/config/file.go
Normal file
54
internal/config/file.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// 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 config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseFile opens the file at the given filename path, then treat each line
|
||||
// not starting with '#' as a configuration statement.
|
||||
func parseFile(o *Config, filename string) error {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
scanner := bufio.NewScanner(fp)
|
||||
n := 0
|
||||
for scanner.Scan() {
|
||||
n += 1
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
err := parseLine(o, line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
148
internal/lmtp/server.go
Normal file
148
internal/lmtp/server.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// 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 lmtp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/receiver"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
)
|
||||
|
||||
// Backend implements smtp.Backend for LMTP server
|
||||
type Backend struct {
|
||||
receiver *receiver.EmailReceiver
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewBackend creates a new LMTP backend
|
||||
func NewBackend(store storage.Storage, cfg *config.Config) *Backend {
|
||||
return &Backend{
|
||||
receiver: receiver.NewEmailReceiver(store, cfg),
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession creates a new SMTP/LMTP session
|
||||
func (b *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
|
||||
return &Session{backend: b}, nil
|
||||
}
|
||||
|
||||
// Session implements smtp.Session for handling LMTP connections
|
||||
type Session struct {
|
||||
backend *Backend
|
||||
from string
|
||||
recipients []string
|
||||
}
|
||||
|
||||
// AuthPlain implements PLAIN authentication (not used for local LMTP)
|
||||
func (s *Session) AuthPlain(username, password string) error {
|
||||
// No authentication required for local LMTP
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mail is called when MAIL FROM command is received
|
||||
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
|
||||
log.Printf("LMTP: MAIL FROM: %s", from)
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rcpt is called when RCPT TO command is received
|
||||
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
log.Printf("LMTP: RCPT TO: %s", to)
|
||||
s.recipients = append(s.recipients, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Data is called when DATA command is received and email content is being transferred
|
||||
func (s *Session) Data(r io.Reader) error {
|
||||
log.Printf("LMTP: Receiving message data for %d recipient(s)", len(s.recipients))
|
||||
|
||||
// Read the entire email
|
||||
emailData, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read email data: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := s.backend.receiver.ProcessEmailBytes(emailData, recipient); err != nil {
|
||||
log.Printf("LMTP: Failed to process email for %s: %v", recipient, err)
|
||||
return fmt.Errorf("failed to process email for %s: %w", recipient, err)
|
||||
}
|
||||
log.Printf("LMTP: Successfully processed email for %s", recipient)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset is called when RSET command is received
|
||||
func (s *Session) Reset() {
|
||||
log.Printf("LMTP: Session reset")
|
||||
s.from = ""
|
||||
s.recipients = nil
|
||||
}
|
||||
|
||||
// Logout is called when the session is closed
|
||||
func (s *Session) Logout() error {
|
||||
log.Printf("LMTP: Session logout")
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartServer starts an LMTP server on the specified address
|
||||
func StartServer(addr string, store storage.Storage, cfg *config.Config) error {
|
||||
backend := NewBackend(store, cfg)
|
||||
|
||||
server := smtp.NewServer(backend)
|
||||
server.Addr = addr
|
||||
server.Domain = cfg.Email.Domain
|
||||
server.AllowInsecureAuth = true
|
||||
server.LMTP = true // Enable LMTP mode
|
||||
|
||||
log.Printf("Starting LMTP server on %s", addr)
|
||||
|
||||
// Create TCP listener explicitly
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create LMTP listener: %w", err)
|
||||
}
|
||||
|
||||
if err := server.Serve(listener); err != nil {
|
||||
return fmt.Errorf("LMTP server error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
203
internal/receiver/receiver.go
Normal file
203
internal/receiver/receiver.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// 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 receiver
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
"git.happydns.org/happyDeliver/pkg/analyzer"
|
||||
)
|
||||
|
||||
// EmailReceiver handles incoming emails from the MTA
|
||||
type EmailReceiver struct {
|
||||
storage storage.Storage
|
||||
config *config.Config
|
||||
analyzer *analyzer.EmailAnalyzer
|
||||
}
|
||||
|
||||
// NewEmailReceiver creates a new email receiver
|
||||
func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver {
|
||||
return &EmailReceiver{
|
||||
storage: store,
|
||||
config: cfg,
|
||||
analyzer: analyzer.NewEmailAnalyzer(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessEmail reads an email from the reader, analyzes it, and stores the results
|
||||
func (r *EmailReceiver) ProcessEmail(emailData io.Reader, recipientEmail string) error {
|
||||
// Read the entire email
|
||||
rawEmail, err := io.ReadAll(emailData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read email: %w", err)
|
||||
}
|
||||
|
||||
return r.ProcessEmailBytes(rawEmail, recipientEmail)
|
||||
}
|
||||
|
||||
// ProcessEmailBytes processes an email from a byte slice
|
||||
func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string) error {
|
||||
|
||||
log.Printf("Received email for %s (%d bytes)", recipientEmail, len(rawEmail))
|
||||
|
||||
// Extract test ID from recipient email address
|
||||
testID, err := r.extractTestID(recipientEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract test ID: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Extracted test ID: %s", testID)
|
||||
|
||||
// Check if a report already exists for this test ID
|
||||
reportExists, err := r.storage.ReportExists(testID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check report existence: %w", err)
|
||||
}
|
||||
|
||||
if reportExists {
|
||||
log.Printf("Report already exists for test %s, skipping analysis", testID)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("Analyzing email for test %s", testID)
|
||||
|
||||
// Analyze the email using the shared analyzer
|
||||
result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
||||
|
||||
// Marshal report to JSON
|
||||
reportJSON, err := json.Marshal(result.Report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
|
||||
// Store the report
|
||||
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil {
|
||||
return fmt.Errorf("failed to store report: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Report stored successfully for test %s", testID)
|
||||
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
|
||||
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
|
||||
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
|
||||
email = strings.Trim(email, "<>")
|
||||
|
||||
// Extract the local part (before @)
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return uuid.Nil, fmt.Errorf("invalid email format: %s", email)
|
||||
}
|
||||
|
||||
localPart := parts[0]
|
||||
|
||||
// Remove the prefix (e.g., "test-")
|
||||
if !strings.HasPrefix(localPart, r.config.Email.TestAddressPrefix) {
|
||||
return uuid.Nil, fmt.Errorf("email does not have expected prefix: %s", email)
|
||||
}
|
||||
|
||||
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
|
||||
|
||||
// Decode Base32 to UUID
|
||||
testID, err := base32ToUUID(uuidStr)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
|
||||
}
|
||||
|
||||
return testID, nil
|
||||
}
|
||||
|
||||
// ExtractRecipientFromHeaders attempts to extract the recipient email from email headers
|
||||
// This is useful when the email is piped and we need to determine the recipient
|
||||
func ExtractRecipientFromHeaders(rawEmail []byte) (string, error) {
|
||||
emailStr := string(rawEmail)
|
||||
|
||||
// Look for common recipient headers
|
||||
headerPatterns := []string{
|
||||
`(?i)^To:\s*(.+)$`,
|
||||
`(?i)^X-Original-To:\s*(.+)$`,
|
||||
`(?i)^Delivered-To:\s*(.+)$`,
|
||||
`(?i)^Envelope-To:\s*(.+)$`,
|
||||
}
|
||||
|
||||
for _, pattern := range headerPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(emailStr)
|
||||
if len(matches) > 1 {
|
||||
recipient := strings.TrimSpace(matches[1])
|
||||
// Clean up the email address
|
||||
recipient = strings.Trim(recipient, "<>")
|
||||
// Take only the first email if there are multiple
|
||||
if idx := strings.Index(recipient, ","); idx != -1 {
|
||||
recipient = recipient[:idx]
|
||||
}
|
||||
if recipient != "" {
|
||||
return recipient, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract recipient from email headers")
|
||||
}
|
||||
46
internal/storage/models.go
Normal file
46
internal/storage/models.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// 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 storage
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Report represents the analysis report for a test
|
||||
type Report struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
TestID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"` // The test ID extracted from email address
|
||||
RawEmail []byte `gorm:"type:bytea;not null"` // Full raw email with headers
|
||||
ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that generates a UUID before creating a report
|
||||
func (r *Report) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
179
internal/storage/storage.go
Normal file
179
internal/storage/storage.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// 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 storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
)
|
||||
|
||||
// Storage interface defines operations for persisting and retrieving data
|
||||
type Storage interface {
|
||||
// Report operations
|
||||
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
|
||||
Close() error
|
||||
}
|
||||
|
||||
// DBStorage implements Storage using GORM
|
||||
type DBStorage struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewStorage creates a new storage instance based on database type
|
||||
func NewStorage(dbType, dsn string) (Storage, error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch dbType {
|
||||
case "sqlite":
|
||||
dialector = sqlite.Open(dsn)
|
||||
case "postgres":
|
||||
dialector = postgres.Open(dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate the schema
|
||||
if err := db.AutoMigrate(&Report{}); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate database schema: %w", err)
|
||||
}
|
||||
|
||||
return &DBStorage{db: db}, nil
|
||||
}
|
||||
|
||||
// CreateReport creates a new report for a test
|
||||
func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) {
|
||||
dbReport := &Report{
|
||||
TestID: testID,
|
||||
RawEmail: rawEmail,
|
||||
ReportJSON: reportJSON,
|
||||
}
|
||||
|
||||
if err := s.db.Create(dbReport).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create report: %w", err)
|
||||
}
|
||||
|
||||
return dbReport, nil
|
||||
}
|
||||
|
||||
// ReportExists checks if a report exists for the given test ID
|
||||
func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&Report{}).Where("test_id = ?", testID).Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("failed to check report existence: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// 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 errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed to get report: %w", err)
|
||||
}
|
||||
|
||||
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{})
|
||||
if result.Error != nil {
|
||||
return 0, fmt.Errorf("failed to delete old reports: %w", result.Error)
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *DBStorage) Close() error {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
75
internal/utils/uuid.go
Normal file
75
internal/utils/uuid.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// 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
|
||||
}
|
||||
26
internal/version/version.go
Normal file
26
internal/version/version.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// 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)"
|
||||
148
pkg/analyzer/analyzer.go
Normal file
148
pkg/analyzer/analyzer.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// 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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
)
|
||||
|
||||
// EmailAnalyzer provides high-level email analysis functionality
|
||||
// This is the main entry point for analyzing emails from both LMTP and CLI
|
||||
type EmailAnalyzer struct {
|
||||
generator *ReportGenerator
|
||||
}
|
||||
|
||||
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
||||
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||
generator := NewReportGenerator(
|
||||
cfg.Analysis.DNSTimeout,
|
||||
cfg.Analysis.HTTPTimeout,
|
||||
cfg.Analysis.RBLs,
|
||||
cfg.Analysis.DNSWLs,
|
||||
cfg.Analysis.CheckAllIPs,
|
||||
)
|
||||
|
||||
return &EmailAnalyzer{
|
||||
generator: generator,
|
||||
}
|
||||
}
|
||||
|
||||
// AnalysisResult contains the complete analysis result
|
||||
type AnalysisResult struct {
|
||||
Email *EmailMessage
|
||||
Results *AnalysisResults
|
||||
Report *api.Report
|
||||
}
|
||||
|
||||
// AnalyzeEmailBytes performs complete email analysis from raw bytes
|
||||
func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) {
|
||||
// Parse the email
|
||||
emailMsg, err := ParseEmail(bytes.NewReader(rawEmail))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse email: %w", err)
|
||||
}
|
||||
|
||||
// Analyze the email
|
||||
results := a.generator.AnalyzeEmail(emailMsg)
|
||||
|
||||
// Generate the report
|
||||
report := a.generator.GenerateReport(testID, results)
|
||||
|
||||
return &AnalysisResult{
|
||||
Email: emailMsg,
|
||||
Results: results,
|
||||
Report: report,
|
||||
}, 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
|
||||
}
|
||||
180
pkg/analyzer/authentication.go
Normal file
180
pkg/analyzer/authentication.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// 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"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// AuthenticationAnalyzer analyzes email authentication results
|
||||
type AuthenticationAnalyzer struct{}
|
||||
|
||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
||||
return &AuthenticationAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
|
||||
results := &api.AuthenticationResults{}
|
||||
|
||||
// Parse Authentication-Results headers
|
||||
authHeaders := email.GetAuthenticationResults()
|
||||
for _, header := range authHeaders {
|
||||
a.parseAuthenticationResultsHeader(header, results)
|
||||
}
|
||||
|
||||
// If no Authentication-Results headers, try to parse legacy headers
|
||||
if results.Spf == nil {
|
||||
results.Spf = a.parseLegacySPF(email)
|
||||
}
|
||||
|
||||
// Parse ARC headers if not already parsed from Authentication-Results
|
||||
if results.Arc == nil {
|
||||
results.Arc = a.parseARCHeaders(email)
|
||||
} else {
|
||||
// Enhance the ARC result with chain information from raw headers
|
||||
a.enhanceARCResult(email, results.Arc)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// parseAuthenticationResultsHeader parses an Authentication-Results header
|
||||
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
|
||||
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
|
||||
// Split by semicolon to get individual results
|
||||
parts := strings.Split(header, ";")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the authserv-id (first part)
|
||||
for i := 1; i < len(parts); i++ {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse SPF
|
||||
if strings.HasPrefix(part, "spf=") {
|
||||
if results.Spf == nil {
|
||||
results.Spf = a.parseSPFResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse DKIM
|
||||
if strings.HasPrefix(part, "dkim=") {
|
||||
dkimResult := a.parseDKIMResult(part)
|
||||
if dkimResult != nil {
|
||||
if results.Dkim == nil {
|
||||
dkimList := []api.AuthResult{*dkimResult}
|
||||
results.Dkim = &dkimList
|
||||
} else {
|
||||
*results.Dkim = append(*results.Dkim, *dkimResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse DMARC
|
||||
if strings.HasPrefix(part, "dmarc=") {
|
||||
if results.Dmarc == nil {
|
||||
results.Dmarc = a.parseDMARCResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse BIMI
|
||||
if strings.HasPrefix(part, "bimi=") {
|
||||
if results.Bimi == nil {
|
||||
results.Bimi = a.parseBIMIResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ARC
|
||||
if strings.HasPrefix(part, "arc=") {
|
||||
if results.Arc == nil {
|
||||
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, ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return score, ScoreToGrade(score)
|
||||
}
|
||||
183
pkg/analyzer/authentication_arc.go
Normal file
183
pkg/analyzer/authentication_arc.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// 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
|
||||
}
|
||||
150
pkg/analyzer/authentication_arc_test.go
Normal file
150
pkg/analyzer/authentication_arc_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
pkg/analyzer/authentication_bimi.go
Normal file
75
pkg/analyzer/authentication_bimi.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// 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
|
||||
}
|
||||
94
pkg/analyzer/authentication_bimi_test.go
Normal file
94
pkg/analyzer/authentication_bimi_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
86
pkg/analyzer/authentication_dkim.go
Normal file
86
pkg/analyzer/authentication_dkim.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// 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
|
||||
}
|
||||
86
pkg/analyzer/authentication_dkim_test.go
Normal file
86
pkg/analyzer/authentication_dkim_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
68
pkg/analyzer/authentication_dmarc.go
Normal file
68
pkg/analyzer/authentication_dmarc.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// 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
|
||||
}
|
||||
69
pkg/analyzer/authentication_dmarc_test.go
Normal file
69
pkg/analyzer/authentication_dmarc_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
73
pkg/analyzer/authentication_iprev.go
Normal file
73
pkg/analyzer/authentication_iprev.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// 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
|
||||
}
|
||||
225
pkg/analyzer/authentication_iprev_test.go
Normal file
225
pkg/analyzer/authentication_iprev_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
105
pkg/analyzer/authentication_spf.go
Normal file
105
pkg/analyzer/authentication_spf.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// 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
|
||||
}
|
||||
212
pkg/analyzer/authentication_spf_test.go
Normal file
212
pkg/analyzer/authentication_spf_test.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
438
pkg/analyzer/authentication_test.go
Normal file
438
pkg/analyzer/authentication_test.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
// 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 TestGetAuthenticationScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *api.AuthenticationResults
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
},
|
||||
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
||||
},
|
||||
{
|
||||
name: "SPF and DKIM only",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
},
|
||||
expectedScore: 48, // SPF=25 + DKIM=23
|
||||
},
|
||||
{
|
||||
name: "SPF fail, DKIM pass",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultFail,
|
||||
},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
},
|
||||
expectedScore: 23, // SPF=0 + DKIM=23
|
||||
},
|
||||
{
|
||||
name: "SPF softfail",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultSoftfail,
|
||||
},
|
||||
},
|
||||
expectedScore: 4,
|
||||
},
|
||||
{
|
||||
name: "No authentication",
|
||||
results: &api.AuthenticationResults{},
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "BIMI adds to score",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Bimi: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
},
|
||||
expectedScore: 35, // SPF (25) + BIMI (10)
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := scorer.CalculateAuthenticationScore(tt.results)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedSPFResult *api.AuthResultResult
|
||||
expectedSPFDomain *string
|
||||
expectedDKIMCount int
|
||||
expectedDKIMResult *api.AuthResultResult
|
||||
expectedDMARCResult *api.AuthResultResult
|
||||
expectedDMARCDomain *string
|
||||
expectedBIMIResult *api.AuthResultResult
|
||||
expectedARCResult *api.ARCResultResult
|
||||
}{
|
||||
{
|
||||
name: "Complete authentication results",
|
||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 1,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "SPF only",
|
||||
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedSPFDomain: api.PtrTo("domain.com"),
|
||||
expectedDKIMCount: 0,
|
||||
expectedDMARCResult: nil,
|
||||
},
|
||||
{
|
||||
name: "DKIM only",
|
||||
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
|
||||
expectedSPFResult: nil,
|
||||
expectedDKIMCount: 1,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
},
|
||||
{
|
||||
name: "Multiple DKIM signatures",
|
||||
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
|
||||
expectedSPFResult: nil,
|
||||
expectedDKIMCount: 2,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCResult: nil,
|
||||
},
|
||||
{
|
||||
name: "SPF fail with DKIM pass",
|
||||
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultFail),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 1,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCResult: nil,
|
||||
},
|
||||
{
|
||||
name: "SPF softfail",
|
||||
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 0,
|
||||
expectedDMARCResult: nil,
|
||||
},
|
||||
{
|
||||
name: "DMARC fail",
|
||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDKIMCount: 1,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
|
||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "BIMI pass",
|
||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 0,
|
||||
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
||||
},
|
||||
{
|
||||
name: "ARC pass",
|
||||
header: "mail.example.com; arc=pass",
|
||||
expectedSPFResult: nil,
|
||||
expectedDKIMCount: 0,
|
||||
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
||||
},
|
||||
{
|
||||
name: "All authentication methods",
|
||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 1,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
||||
},
|
||||
{
|
||||
name: "Empty header (authserv-id only)",
|
||||
header: "mx.google.com",
|
||||
expectedSPFResult: nil,
|
||||
expectedDKIMCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Empty parts with semicolons",
|
||||
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 0,
|
||||
},
|
||||
{
|
||||
name: "DKIM with short form parameters",
|
||||
header: "mail.example.com; dkim=pass d=example.com s=selector1",
|
||||
expectedSPFResult: nil,
|
||||
expectedDKIMCount: 1,
|
||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||
},
|
||||
{
|
||||
name: "SPF neutral",
|
||||
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral),
|
||||
expectedSPFDomain: api.PtrTo("example.com"),
|
||||
expectedDKIMCount: 0,
|
||||
},
|
||||
{
|
||||
name: "SPF none",
|
||||
header: "mail.example.com; spf=none",
|
||||
expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
|
||||
expectedDKIMCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||
|
||||
// Check SPF
|
||||
if tt.expectedSPFResult != nil {
|
||||
if results.Spf == nil {
|
||||
t.Errorf("Expected SPF result, got nil")
|
||||
} else {
|
||||
if results.Spf.Result != *tt.expectedSPFResult {
|
||||
t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult)
|
||||
}
|
||||
if tt.expectedSPFDomain != nil {
|
||||
if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain {
|
||||
var gotDomain string
|
||||
if results.Spf.Domain != nil {
|
||||
gotDomain = *results.Spf.Domain
|
||||
}
|
||||
t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if results.Spf != nil {
|
||||
t.Errorf("Expected no SPF result, got %+v", results.Spf)
|
||||
}
|
||||
}
|
||||
|
||||
// Check DKIM count and result
|
||||
if results.Dkim == nil {
|
||||
if tt.expectedDKIMCount != 0 {
|
||||
t.Errorf("Expected %d DKIM results, got nil", tt.expectedDKIMCount)
|
||||
}
|
||||
} else {
|
||||
if len(*results.Dkim) != tt.expectedDKIMCount {
|
||||
t.Errorf("DKIM count = %d, want %d", len(*results.Dkim), tt.expectedDKIMCount)
|
||||
}
|
||||
if tt.expectedDKIMResult != nil && len(*results.Dkim) > 0 {
|
||||
if (*results.Dkim)[0].Result != *tt.expectedDKIMResult {
|
||||
t.Errorf("DKIM Result = %v, want %v", (*results.Dkim)[0].Result, *tt.expectedDKIMResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check DMARC
|
||||
if tt.expectedDMARCResult != nil {
|
||||
if results.Dmarc == nil {
|
||||
t.Errorf("Expected DMARC result, got nil")
|
||||
} else {
|
||||
if results.Dmarc.Result != *tt.expectedDMARCResult {
|
||||
t.Errorf("DMARC Result = %v, want %v", results.Dmarc.Result, *tt.expectedDMARCResult)
|
||||
}
|
||||
if tt.expectedDMARCDomain != nil {
|
||||
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != *tt.expectedDMARCDomain {
|
||||
var gotDomain string
|
||||
if results.Dmarc.Domain != nil {
|
||||
gotDomain = *results.Dmarc.Domain
|
||||
}
|
||||
t.Errorf("DMARC Domain = %v, want %v", gotDomain, *tt.expectedDMARCDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if results.Dmarc != nil {
|
||||
t.Errorf("Expected no DMARC result, got %+v", results.Dmarc)
|
||||
}
|
||||
}
|
||||
|
||||
// Check BIMI
|
||||
if tt.expectedBIMIResult != nil {
|
||||
if results.Bimi == nil {
|
||||
t.Errorf("Expected BIMI result, got nil")
|
||||
} else {
|
||||
if results.Bimi.Result != *tt.expectedBIMIResult {
|
||||
t.Errorf("BIMI Result = %v, want %v", results.Bimi.Result, *tt.expectedBIMIResult)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if results.Bimi != nil {
|
||||
t.Errorf("Expected no BIMI result, got %+v", results.Bimi)
|
||||
}
|
||||
}
|
||||
|
||||
// Check ARC
|
||||
if tt.expectedARCResult != nil {
|
||||
if results.Arc == nil {
|
||||
t.Errorf("Expected ARC result, got nil")
|
||||
} else {
|
||||
if results.Arc.Result != *tt.expectedARCResult {
|
||||
t.Errorf("ARC Result = %v, want %v", results.Arc.Result, *tt.expectedARCResult)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if results.Arc != nil {
|
||||
t.Errorf("Expected no ARC result, got %+v", results.Arc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||
// This test verifies that only the first occurrence of each auth method is parsed
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
||||
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||
|
||||
if results.Spf == nil {
|
||||
t.Fatal("Expected SPF result, got nil")
|
||||
}
|
||||
if results.Spf.Result != api.AuthResultResultPass {
|
||||
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
|
||||
}
|
||||
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
|
||||
t.Errorf("Expected domain from first SPF result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
|
||||
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||
|
||||
if results.Dmarc == nil {
|
||||
t.Fatal("Expected DMARC result, got nil")
|
||||
}
|
||||
if results.Dmarc.Result != api.AuthResultResultPass {
|
||||
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
|
||||
}
|
||||
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
|
||||
t.Errorf("Expected domain from first DMARC result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
|
||||
header := "mail.example.com; arc=pass; arc=fail"
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||
|
||||
if results.Arc == nil {
|
||||
t.Fatal("Expected ARC result, got nil")
|
||||
}
|
||||
if results.Arc.Result != api.ARCResultResultPass {
|
||||
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
|
||||
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||
|
||||
if results.Bimi == nil {
|
||||
t.Fatal("Expected BIMI result, got nil")
|
||||
}
|
||||
if results.Bimi.Result != api.AuthResultResultPass {
|
||||
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
|
||||
}
|
||||
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
|
||||
t.Errorf("Expected domain from first BIMI result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
|
||||
// DKIM is special - multiple signatures should all be collected
|
||||
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||
|
||||
if results.Dkim == nil {
|
||||
t.Fatal("Expected DKIM results, got nil")
|
||||
}
|
||||
if len(*results.Dkim) != 2 {
|
||||
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
|
||||
}
|
||||
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
|
||||
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
|
||||
}
|
||||
if (*results.Dkim)[1].Result != api.AuthResultResultFail {
|
||||
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
|
||||
}
|
||||
})
|
||||
}
|
||||
65
pkg/analyzer/authentication_x_aligned_from.go
Normal file
65
pkg/analyzer/authentication_x_aligned_from.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// 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
|
||||
}
|
||||
144
pkg/analyzer/authentication_x_aligned_from_test.go
Normal file
144
pkg/analyzer/authentication_x_aligned_from_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
73
pkg/analyzer/authentication_x_google_dkim.go
Normal file
73
pkg/analyzer/authentication_x_google_dkim.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// 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
|
||||
}
|
||||
83
pkg/analyzer/authentication_x_google_dkim_test.go
Normal file
83
pkg/analyzer/authentication_x_google_dkim_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
986
pkg/analyzer/content.go
Normal file
986
pkg/analyzer/content.go
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
// 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"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ContentAnalyzer analyzes email content (HTML, links, images)
|
||||
type ContentAnalyzer struct {
|
||||
Timeout time.Duration
|
||||
httpClient *http.Client
|
||||
listUnsubscribeURLs []string // URLs from List-Unsubscribe header
|
||||
hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
}
|
||||
|
||||
// NewContentAnalyzer creates a new content analyzer with configurable timeout
|
||||
func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second // Default timeout
|
||||
}
|
||||
return &ContentAnalyzer{
|
||||
Timeout: timeout,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Allow up to 10 redirects
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ContentResults represents content analysis results
|
||||
type ContentResults struct {
|
||||
IsMultipart bool
|
||||
HTMLValid bool
|
||||
HTMLErrors []string
|
||||
Links []LinkCheck
|
||||
Images []ImageCheck
|
||||
HasUnsubscribe bool
|
||||
UnsubscribeLinks []string
|
||||
TextContent string
|
||||
HTMLContent string
|
||||
TextPlainRatio float32 // Ratio of plain text to HTML consistency
|
||||
ImageTextRatio float32 // Ratio of images to text
|
||||
SuspiciousURLs []string
|
||||
ContentIssues []string
|
||||
HarmfullIssues []string
|
||||
}
|
||||
|
||||
// HasPlaintext returns true if the email has plain text content
|
||||
func (r *ContentResults) HasPlaintext() bool {
|
||||
return r.TextContent != ""
|
||||
}
|
||||
|
||||
// LinkCheck represents a link validation result
|
||||
type LinkCheck struct {
|
||||
URL string
|
||||
Valid bool
|
||||
Status int
|
||||
Error string
|
||||
IsSafe bool
|
||||
Warning string
|
||||
}
|
||||
|
||||
// ImageCheck represents an image validation result
|
||||
type ImageCheck struct {
|
||||
Src string
|
||||
HasAlt bool
|
||||
AltText string
|
||||
Valid bool
|
||||
Error string
|
||||
IsBroken bool
|
||||
}
|
||||
|
||||
// AnalyzeContent performs content analysis on email message
|
||||
func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
|
||||
results := &ContentResults{}
|
||||
|
||||
results.IsMultipart = len(email.Parts) > 1
|
||||
|
||||
// Parse List-Unsubscribe header URLs for use in link detection
|
||||
c.listUnsubscribeURLs = email.GetListUnsubscribeURLs()
|
||||
|
||||
// Check for one-click unsubscribe support
|
||||
listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post")
|
||||
c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click")
|
||||
|
||||
// Get HTML and text parts
|
||||
htmlParts := email.GetHTMLParts()
|
||||
textParts := email.GetTextParts()
|
||||
|
||||
// Analyze HTML parts
|
||||
if len(htmlParts) > 0 {
|
||||
for _, part := range htmlParts {
|
||||
c.analyzeHTML(part.Content, results)
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze text parts
|
||||
if len(textParts) > 0 {
|
||||
for _, part := range textParts {
|
||||
results.TextContent += part.Content
|
||||
}
|
||||
// Extract and validate links from plain text
|
||||
c.analyzeTextLinks(results.TextContent, results)
|
||||
}
|
||||
|
||||
// Check plain text/HTML consistency
|
||||
if len(htmlParts) > 0 && len(textParts) > 0 {
|
||||
results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent)
|
||||
} else if !results.IsMultipart {
|
||||
results.TextPlainRatio = 1.0
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// analyzeTextLinks extracts and validates URLs from plain text
|
||||
func (c *ContentAnalyzer) analyzeTextLinks(textContent string, results *ContentResults) {
|
||||
// Regular expression to match URLs in plain text
|
||||
// Matches http://, https://, and www. URLs
|
||||
urlRegex := regexp.MustCompile(`(?i)\b(?:https?://|www\.)[^\s<>"{}|\\^\[\]` + "`" + `]+`)
|
||||
|
||||
matches := urlRegex.FindAllString(textContent, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
// Normalize URL (add http:// if missing)
|
||||
urlStr := match
|
||||
if strings.HasPrefix(strings.ToLower(urlStr), "www.") {
|
||||
urlStr = "http://" + urlStr
|
||||
}
|
||||
|
||||
// Check if this URL already exists in results.Links (from HTML analysis)
|
||||
exists := false
|
||||
for _, link := range results.Links {
|
||||
if link.URL == urlStr {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only validate if not already checked
|
||||
if !exists {
|
||||
linkCheck := c.validateLink(urlStr)
|
||||
results.Links = append(results.Links, linkCheck)
|
||||
|
||||
// Check for suspicious URLs
|
||||
if !linkCheck.IsSafe {
|
||||
results.SuspiciousURLs = append(results.SuspiciousURLs, urlStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeHTML parses and analyzes HTML content
|
||||
func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) {
|
||||
results.HTMLContent = htmlContent
|
||||
|
||||
// Parse HTML
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
results.HTMLValid = false
|
||||
results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
results.HTMLValid = true
|
||||
|
||||
// Traverse HTML tree
|
||||
c.traverseHTML(doc, results)
|
||||
|
||||
// Calculate image-to-text ratio
|
||||
if results.HTMLContent != "" {
|
||||
textLength := len(c.extractTextFromHTML(htmlContent))
|
||||
imageCount := len(results.Images)
|
||||
if textLength > 0 {
|
||||
results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// traverseHTML recursively traverses HTML nodes
|
||||
func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "a":
|
||||
// Extract and validate links
|
||||
href := c.getAttr(n, "href")
|
||||
if href != "" {
|
||||
// Check for unsubscribe links
|
||||
if c.isUnsubscribeLink(href, n) {
|
||||
results.HasUnsubscribe = true
|
||||
results.UnsubscribeLinks = append(results.UnsubscribeLinks, href)
|
||||
}
|
||||
|
||||
// Validate link
|
||||
linkCheck := c.validateLink(href)
|
||||
|
||||
// Check for domain misalignment (phishing detection)
|
||||
linkText := c.getNodeText(n)
|
||||
if c.hasDomainMisalignment(href, linkText) {
|
||||
linkCheck.IsSafe = false
|
||||
if linkCheck.Warning == "" {
|
||||
linkCheck.Warning = "Link text domain does not match actual URL domain (possible phishing)"
|
||||
} else {
|
||||
linkCheck.Warning += "; Link text domain does not match actual URL domain (possible phishing)"
|
||||
}
|
||||
}
|
||||
|
||||
results.Links = append(results.Links, linkCheck)
|
||||
|
||||
// Check for suspicious URLs
|
||||
if !linkCheck.IsSafe {
|
||||
results.SuspiciousURLs = append(results.SuspiciousURLs, href)
|
||||
}
|
||||
}
|
||||
|
||||
case "img":
|
||||
// Extract and validate images
|
||||
src := c.getAttr(n, "src")
|
||||
alt := c.getAttr(n, "alt")
|
||||
|
||||
imageCheck := ImageCheck{
|
||||
Src: src,
|
||||
HasAlt: alt != "",
|
||||
AltText: alt,
|
||||
Valid: src != "",
|
||||
}
|
||||
|
||||
if src == "" {
|
||||
imageCheck.Error = "Image missing src attribute"
|
||||
}
|
||||
|
||||
results.Images = append(results.Images, imageCheck)
|
||||
|
||||
case "script":
|
||||
// JavaScript in emails is a security risk and typically blocked
|
||||
results.HarmfullIssues = append(results.HarmfullIssues, "Dangerous <script> tag detected - JavaScript is blocked by most email clients")
|
||||
|
||||
case "iframe":
|
||||
// Iframes are security risks and blocked by most email clients
|
||||
src := c.getAttr(n, "src")
|
||||
issue := "Dangerous <iframe> tag detected"
|
||||
if src != "" {
|
||||
issue += fmt.Sprintf(" with src='%s'", src)
|
||||
}
|
||||
results.HarmfullIssues = append(results.HarmfullIssues, issue+" - iframes are blocked by most email clients")
|
||||
|
||||
case "object", "embed", "applet":
|
||||
// Legacy embedding tags, security risks
|
||||
results.HarmfullIssues = append(results.HarmfullIssues, fmt.Sprintf("Dangerous <%s> tag detected - legacy embedding tags are security risks and blocked by email clients", n.Data))
|
||||
|
||||
case "form":
|
||||
// Forms in emails can be phishing vectors
|
||||
action := c.getAttr(n, "action")
|
||||
issue := "Suspicious <form> tag detected"
|
||||
if action != "" {
|
||||
issue += fmt.Sprintf(" with action='%s'", action)
|
||||
}
|
||||
results.HarmfullIssues = append(results.HarmfullIssues, issue+" - forms can be phishing vectors and are often blocked")
|
||||
|
||||
case "base":
|
||||
// Base tag can be used for phishing by redirecting relative URLs
|
||||
href := c.getAttr(n, "href")
|
||||
issue := "Potentially dangerous <base> tag detected"
|
||||
if href != "" {
|
||||
issue += fmt.Sprintf(" with href='%s'", href)
|
||||
}
|
||||
results.HarmfullIssues = append(results.HarmfullIssues, issue+" - can redirect all relative URLs")
|
||||
|
||||
case "meta":
|
||||
// Check for suspicious meta redirects
|
||||
httpEquiv := c.getAttr(n, "http-equiv")
|
||||
if strings.ToLower(httpEquiv) == "refresh" {
|
||||
content := c.getAttr(n, "content")
|
||||
results.HarmfullIssues = append(results.HarmfullIssues, fmt.Sprintf("Suspicious <meta http-equiv='refresh'> tag detected with content='%s' - can be used for phishing redirects", content))
|
||||
}
|
||||
|
||||
case "link":
|
||||
// Check for external stylesheet links (potential privacy/tracking concerns)
|
||||
rel := c.getAttr(n, "rel")
|
||||
href := c.getAttr(n, "href")
|
||||
if strings.Contains(strings.ToLower(rel), "stylesheet") && href != "" {
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
results.ContentIssues = append(results.ContentIssues, fmt.Sprintf("External stylesheet link detected: %s - may cause rendering issues or privacy concerns", href))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse children
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
c.traverseHTML(child, results)
|
||||
}
|
||||
}
|
||||
|
||||
// getAttr gets an attribute value from an HTML node
|
||||
func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key == key {
|
||||
return attr.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isUnsubscribeLink checks if a link is an unsubscribe link
|
||||
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
||||
// First check: does the href match a URL from the List-Unsubscribe header?
|
||||
if slices.Contains(c.listUnsubscribeURLs, href) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check href for unsubscribe keywords
|
||||
lowerHref := strings.ToLower(href)
|
||||
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"}
|
||||
for _, keyword := range unsubKeywords {
|
||||
if strings.Contains(lowerHref, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check link text for unsubscribe keywords
|
||||
text := c.getNodeText(node)
|
||||
lowerText := strings.ToLower(text)
|
||||
for _, keyword := range unsubKeywords {
|
||||
if strings.Contains(lowerText, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getNodeText extracts text content from a node
|
||||
func (c *ContentAnalyzer) getNodeText(n *html.Node) string {
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
var text string
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
text += c.getNodeText(child)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// validateLink validates a URL and checks if it's accessible
|
||||
func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck {
|
||||
check := LinkCheck{
|
||||
URL: urlStr,
|
||||
IsSafe: true,
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
check.Valid = false
|
||||
check.Error = fmt.Sprintf("Invalid URL: %v", err)
|
||||
return check
|
||||
}
|
||||
|
||||
// Check URL safety
|
||||
if c.isSuspiciousURL(urlStr, parsedURL) {
|
||||
check.IsSafe = false
|
||||
check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)"
|
||||
}
|
||||
|
||||
// Only check HTTP/HTTPS links
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
check.Valid = true
|
||||
return check
|
||||
}
|
||||
|
||||
// Check if link is accessible (with timeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil)
|
||||
if err != nil {
|
||||
check.Valid = false
|
||||
check.Error = fmt.Sprintf("Failed to create request: %v", err)
|
||||
return check
|
||||
}
|
||||
|
||||
// Set a reasonable user agent
|
||||
req.Header.Set("User-Agent", "happyDeliver/1.0 (Email Deliverability Tester)")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Don't fail on timeout/connection errors for external links
|
||||
// Just mark as warning
|
||||
check.Valid = true
|
||||
check.Status = 0
|
||||
check.Warning = fmt.Sprintf("Could not verify link: %v", err)
|
||||
return check
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
check.Status = resp.StatusCode
|
||||
check.Valid = true
|
||||
|
||||
// Check for error status codes
|
||||
if resp.StatusCode >= 400 {
|
||||
check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode)
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// hasDomainMisalignment checks if the link text contains a different domain than the actual URL
|
||||
// This is a common phishing technique (e.g., text shows "paypal.com" but links to "evil.com")
|
||||
func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||
// Parse the actual URL
|
||||
parsedURL, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the actual destination domain/email based on scheme
|
||||
var actualDomain string
|
||||
|
||||
switch parsedURL.Scheme {
|
||||
case "mailto":
|
||||
// Extract email address from mailto: URL
|
||||
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
|
||||
mailtoAddr := parsedURL.Opaque
|
||||
|
||||
// Remove query parameters if present
|
||||
if idx := strings.Index(mailtoAddr, "?"); idx != -1 {
|
||||
mailtoAddr = mailtoAddr[:idx]
|
||||
}
|
||||
|
||||
mailtoAddr = strings.TrimSpace(strings.ToLower(mailtoAddr))
|
||||
|
||||
// Extract domain from email address
|
||||
if idx := strings.Index(mailtoAddr, "@"); idx != -1 {
|
||||
actualDomain = mailtoAddr[idx+1:]
|
||||
} else {
|
||||
return false // Invalid mailto
|
||||
}
|
||||
case "http":
|
||||
case "https":
|
||||
// Check if URL has a host
|
||||
if parsedURL.Host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the actual URL's domain (remove port if present)
|
||||
actualDomain = parsedURL.Host
|
||||
if idx := strings.LastIndex(actualDomain, ":"); idx != -1 {
|
||||
actualDomain = actualDomain[:idx]
|
||||
}
|
||||
actualDomain = strings.ToLower(actualDomain)
|
||||
default:
|
||||
// Skip checks for other URL schemes (tel, etc.)
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize link text
|
||||
linkText = strings.TrimSpace(linkText)
|
||||
linkText = strings.ToLower(linkText)
|
||||
|
||||
// Skip if link text is empty, too short, or just generic text like "click here"
|
||||
if linkText == "" || len(linkText) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Common generic link texts that shouldn't trigger warnings
|
||||
genericTexts := []string{
|
||||
"click here", "read more", "learn more", "download", "subscribe",
|
||||
"unsubscribe", "view online", "view in browser", "click", "here",
|
||||
"update", "verify", "confirm", "continue", "get started",
|
||||
// mailto-specific generic texts
|
||||
"email us", "contact us", "send email", "get in touch", "reach out",
|
||||
"contact", "email", "write to us",
|
||||
}
|
||||
if slices.Contains(genericTexts, linkText) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract domain-like patterns from link text using regex
|
||||
// Matches patterns like "example.com", "www.example.com", "http://example.com"
|
||||
domainRegex := regexp.MustCompile(`(?i)(?:https?://)?(?:www\.)?([a-z0-9][-a-z0-9]*\.)+[a-z]{2,}`)
|
||||
matches := domainRegex.FindAllString(linkText, -1)
|
||||
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check each domain-like pattern found in the text
|
||||
for _, textDomain := range matches {
|
||||
// Normalize the text domain
|
||||
textDomain = strings.ToLower(textDomain)
|
||||
textDomain = strings.TrimPrefix(textDomain, "http://")
|
||||
textDomain = strings.TrimPrefix(textDomain, "https://")
|
||||
textDomain = strings.TrimPrefix(textDomain, "www.")
|
||||
|
||||
// Remove trailing slashes and paths
|
||||
if idx := strings.Index(textDomain, "/"); idx != -1 {
|
||||
textDomain = textDomain[:idx]
|
||||
}
|
||||
|
||||
// Compare domains - they should match or the actual URL should be a subdomain of the text domain
|
||||
if textDomain != actualDomain {
|
||||
// Check if actual domain is a subdomain of text domain
|
||||
if !strings.HasSuffix(actualDomain, "."+textDomain) && !strings.HasSuffix(actualDomain, textDomain) {
|
||||
// Check if they share the same base domain (last 2 parts)
|
||||
textParts := strings.Split(textDomain, ".")
|
||||
actualParts := strings.Split(actualDomain, ".")
|
||||
|
||||
if len(textParts) >= 2 && len(actualParts) >= 2 {
|
||||
textBase := strings.Join(textParts[len(textParts)-2:], ".")
|
||||
actualBase := strings.Join(actualParts[len(actualParts)-2:], ".")
|
||||
|
||||
if textBase != actualBase {
|
||||
return true // Domain mismatch detected!
|
||||
}
|
||||
} else {
|
||||
return true // Domain mismatch detected!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isSuspiciousURL checks if a URL looks suspicious
|
||||
func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool {
|
||||
// Skip checks for mailto: URLs
|
||||
if parsedURL.Scheme == "mailto" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for IP address instead of domain
|
||||
if c.isIPAddress(parsedURL.Host) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for URL shorteners (common ones)
|
||||
shorteners := []string{
|
||||
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
||||
"buff.ly", "is.gd", "bl.ink", "short.io",
|
||||
}
|
||||
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for excessive subdomains (possible obfuscation)
|
||||
parts := strings.Split(parsedURL.Host, ".")
|
||||
if len(parts) > 4 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for URL obfuscation techniques
|
||||
if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for suspicious characters in domain
|
||||
if strings.ContainsAny(parsedURL.Host, "[]()<>") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isIPAddress checks if a string is an IP address
|
||||
func (c *ContentAnalyzer) isIPAddress(host string) bool {
|
||||
// Remove port if present
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
// Simple check for IPv4
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) == 4 {
|
||||
for _, part := range parts {
|
||||
// Check if all characters are digits
|
||||
for _, ch := range part {
|
||||
if !unicode.IsDigit(ch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for IPv6 (contains colons)
|
||||
if strings.Contains(host, ":") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractTextFromHTML extracts plain text from HTML
|
||||
func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
var extract func(*html.Node)
|
||||
extract = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
text.WriteString(" " + n.Data)
|
||||
}
|
||||
// Skip script and style tags
|
||||
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
extract(child)
|
||||
}
|
||||
}
|
||||
extract(doc)
|
||||
|
||||
return strings.TrimSpace(text.String())
|
||||
}
|
||||
|
||||
// calculateTextPlainConsistency compares plain text and HTML versions
|
||||
func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 {
|
||||
// Extract text from HTML
|
||||
htmlPlainText := c.extractTextFromHTML(htmlText)
|
||||
|
||||
// Normalize both texts
|
||||
plainNorm := c.normalizeText(plainText)
|
||||
htmlNorm := c.normalizeText(htmlPlainText)
|
||||
|
||||
// Calculate similarity using simple word overlap
|
||||
plainWords := strings.Fields(plainNorm)
|
||||
htmlWords := strings.Fields(htmlNorm)
|
||||
|
||||
if len(plainWords) == 0 || len(htmlWords) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Count common words by building sets
|
||||
plainWordSet := make(map[string]int)
|
||||
for _, word := range plainWords {
|
||||
plainWordSet[word]++
|
||||
}
|
||||
|
||||
htmlWordSet := make(map[string]int)
|
||||
for _, word := range htmlWords {
|
||||
htmlWordSet[word]++
|
||||
}
|
||||
|
||||
// Count matches: for each unique word, count minimum occurrences in both texts
|
||||
commonWords := 0
|
||||
for word, plainCount := range plainWordSet {
|
||||
if htmlCount, exists := htmlWordSet[word]; exists {
|
||||
// Count the minimum occurrences between both texts
|
||||
if plainCount < htmlCount {
|
||||
commonWords += plainCount
|
||||
} else {
|
||||
commonWords += htmlCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ratio using total words from both texts (union approach)
|
||||
// This provides a balanced measure: perfect match = 1.0, partial overlap = 0.3-0.8
|
||||
totalWords := len(plainWords) + len(htmlWords)
|
||||
if totalWords == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Divide by average word count for better scoring
|
||||
avgWords := float32(totalWords) / 2.0
|
||||
ratio := float32(commonWords) / avgWords
|
||||
|
||||
// Cap at 1.0 for perfect matches
|
||||
if ratio > 1.0 {
|
||||
ratio = 1.0
|
||||
}
|
||||
|
||||
return ratio
|
||||
}
|
||||
|
||||
// normalizeText normalizes text for comparison
|
||||
func (c *ContentAnalyzer) normalizeText(text string) string {
|
||||
// Convert to lowercase
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// Remove extra whitespace
|
||||
text = strings.TrimSpace(text)
|
||||
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// GenerateContentAnalysis creates structured content analysis from results
|
||||
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
|
||||
if results == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
analysis := &api.ContentAnalysis{
|
||||
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
||||
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
||||
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
||||
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
|
||||
}
|
||||
|
||||
// Calculate text-to-image ratio (inverse of image-to-text)
|
||||
if len(results.Images) > 0 && results.HTMLContent != "" {
|
||||
textLen := float32(len(c.extractTextFromHTML(results.HTMLContent)))
|
||||
if textLen > 0 {
|
||||
ratio := textLen / float32(len(results.Images))
|
||||
analysis.TextToImageRatio = &ratio
|
||||
}
|
||||
}
|
||||
|
||||
// Build HTML issues
|
||||
htmlIssues := []api.ContentIssue{}
|
||||
|
||||
// Add HTML parsing errors
|
||||
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
|
||||
for _, errMsg := range results.HTMLErrors {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.BrokenHtml,
|
||||
Severity: api.ContentIssueSeverityHigh,
|
||||
Message: errMsg,
|
||||
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing alt text issues
|
||||
if len(results.Images) > 0 {
|
||||
missingAltCount := 0
|
||||
for _, img := range results.Images {
|
||||
if !img.HasAlt {
|
||||
missingAltCount++
|
||||
}
|
||||
}
|
||||
if missingAltCount > 0 {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.MissingAlt,
|
||||
Severity: api.ContentIssueSeverityMedium,
|
||||
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
|
||||
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add excessive images issue
|
||||
if results.ImageTextRatio > 10.0 {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.ExcessiveImages,
|
||||
Severity: api.ContentIssueSeverityMedium,
|
||||
Message: "Email is excessively image-heavy",
|
||||
Advice: api.PtrTo("Reduce the number of images relative to text content"),
|
||||
})
|
||||
}
|
||||
|
||||
// Add suspicious URL issues
|
||||
for _, suspURL := range results.SuspiciousURLs {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.SuspiciousLink,
|
||||
Severity: api.ContentIssueSeverityHigh,
|
||||
Message: "Suspicious URL detected",
|
||||
Location: &suspURL,
|
||||
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
|
||||
})
|
||||
}
|
||||
|
||||
// Add harmful HTML tag issues
|
||||
for _, harmfulIssue := range results.HarmfullIssues {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.DangerousHtml,
|
||||
Severity: api.ContentIssueSeverityCritical,
|
||||
Message: harmfulIssue,
|
||||
Advice: api.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
|
||||
})
|
||||
}
|
||||
|
||||
// Add general content issues (like external stylesheets)
|
||||
for _, contentIssue := range results.ContentIssues {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.BrokenHtml,
|
||||
Severity: api.ContentIssueSeverityLow,
|
||||
Message: contentIssue,
|
||||
Advice: api.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
|
||||
})
|
||||
}
|
||||
|
||||
if len(htmlIssues) > 0 {
|
||||
analysis.HtmlIssues = &htmlIssues
|
||||
}
|
||||
|
||||
// Convert links
|
||||
if len(results.Links) > 0 {
|
||||
links := make([]api.LinkCheck, 0, len(results.Links))
|
||||
for _, link := range results.Links {
|
||||
status := api.Valid
|
||||
if link.Status >= 400 {
|
||||
status = api.Broken
|
||||
} else if !link.IsSafe {
|
||||
status = api.Suspicious
|
||||
} else if link.Warning != "" {
|
||||
status = api.Timeout
|
||||
}
|
||||
|
||||
apiLink := api.LinkCheck{
|
||||
Url: link.URL,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if link.Status > 0 {
|
||||
apiLink.HttpCode = api.PtrTo(link.Status)
|
||||
}
|
||||
|
||||
// Check if it's a URL shortener
|
||||
parsedURL, err := url.Parse(link.URL)
|
||||
if err == nil {
|
||||
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
|
||||
apiLink.IsShortened = api.PtrTo(isShortened)
|
||||
}
|
||||
|
||||
links = append(links, apiLink)
|
||||
}
|
||||
analysis.Links = &links
|
||||
}
|
||||
|
||||
// Convert images
|
||||
if len(results.Images) > 0 {
|
||||
images := make([]api.ImageCheck, 0, len(results.Images))
|
||||
for _, img := range results.Images {
|
||||
apiImg := api.ImageCheck{
|
||||
HasAlt: img.HasAlt,
|
||||
}
|
||||
if img.Src != "" {
|
||||
apiImg.Src = &img.Src
|
||||
}
|
||||
if img.AltText != "" {
|
||||
apiImg.AltText = &img.AltText
|
||||
}
|
||||
// Simple heuristic: tracking pixels are typically 1x1
|
||||
apiImg.IsTrackingPixel = api.PtrTo(false)
|
||||
|
||||
images = append(images, apiImg)
|
||||
}
|
||||
analysis.Images = &images
|
||||
}
|
||||
|
||||
// Unsubscribe methods
|
||||
if results.HasUnsubscribe {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
|
||||
}
|
||||
|
||||
for _, url := range c.listUnsubscribeURLs {
|
||||
if strings.HasPrefix(url, "mailto:") {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
|
||||
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
|
||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
|
||||
}
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// CalculateContentScore calculates the content score (0-20 points)
|
||||
func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) (int, string) {
|
||||
if results == nil {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
var score int = 10
|
||||
|
||||
// HTML validity or text alone (10 points)
|
||||
if results.HTMLValid || (!results.IsMultipart && results.HasPlaintext()) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Requires plain text alternative (10 points)
|
||||
if results.HasPlaintext() {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Links (25 points)
|
||||
if len(results.Links) > 0 {
|
||||
brokenLinks := 0
|
||||
for _, link := range results.Links {
|
||||
if link.Status >= 400 {
|
||||
brokenLinks++
|
||||
}
|
||||
}
|
||||
score += 20 * (len(results.Links) - brokenLinks) / len(results.Links)
|
||||
// Too much links, 10 points penalty
|
||||
if len(results.Links) > 30 {
|
||||
score -= 10
|
||||
}
|
||||
} else {
|
||||
// No links is better, less suspiscous
|
||||
score += 25
|
||||
}
|
||||
|
||||
// Images (15 points)
|
||||
if len(results.Images) > 0 {
|
||||
noAltCount := 0
|
||||
for _, img := range results.Images {
|
||||
if !img.HasAlt {
|
||||
noAltCount++
|
||||
}
|
||||
}
|
||||
score += 15 * (len(results.Images) - noAltCount) / len(results.Images)
|
||||
} else {
|
||||
// No images is Ok
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Text consistency (15 points)
|
||||
if results.TextPlainRatio >= 0.3 {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Image ratio (15 points)
|
||||
if results.ImageTextRatio <= 5.0 {
|
||||
score += 15
|
||||
} else if results.ImageTextRatio <= 10.0 {
|
||||
score += 7
|
||||
}
|
||||
|
||||
// Penalize suspicious URLs (deduct up to 5 points)
|
||||
if len(results.SuspiciousURLs) > 0 {
|
||||
score -= min(len(results.SuspiciousURLs), 5)
|
||||
}
|
||||
|
||||
// Penalize harmful HTML tags (deduct 20 points per harmful tag, max 40 points)
|
||||
if len(results.HarmfullIssues) > 0 {
|
||||
score -= min(len(results.HarmfullIssues)*20, 40)
|
||||
}
|
||||
|
||||
// Ensure score is between 0 and 100
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
return score, ScoreToGrade(score)
|
||||
}
|
||||
|
|
@ -28,7 +28,6 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
|
|
@ -77,17 +76,17 @@ func TestExtractTextFromHTML(t *testing.T) {
|
|||
{
|
||||
name: "Multiple elements",
|
||||
html: "<div><h1>Title</h1><p>Paragraph</p></div>",
|
||||
expectedText: "TitleParagraph",
|
||||
expectedText: "Title Paragraph",
|
||||
},
|
||||
{
|
||||
name: "With script tag",
|
||||
html: "<p>Text</p><script>alert('hi')</script><p>More</p>",
|
||||
expectedText: "TextMore",
|
||||
expectedText: "Text More",
|
||||
},
|
||||
{
|
||||
name: "With style tag",
|
||||
html: "<p>Text</p><style>.class { color: red; }</style><p>More</p>",
|
||||
expectedText: "TextMore",
|
||||
expectedText: "Text More",
|
||||
},
|
||||
{
|
||||
name: "Empty HTML",
|
||||
|
|
@ -145,6 +144,74 @@ func TestIsUnsubscribeLink(t *testing.T) {
|
|||
linkText: "Read more",
|
||||
expected: false,
|
||||
},
|
||||
// Multilingual keyword detection - URL path
|
||||
{
|
||||
name: "German abmelden in URL",
|
||||
href: "https://example.com/abmelden?id=42",
|
||||
linkText: "Click here",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
|
||||
href: "https://example.com/se-desabonner?id=42",
|
||||
linkText: "Click here",
|
||||
expected: false,
|
||||
},
|
||||
// Multilingual keyword detection - link text
|
||||
{
|
||||
name: "German Abmelden in link text",
|
||||
href: "https://example.com/manage?id=42&lang=de",
|
||||
linkText: "Abmelden",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "French Se désabonner in link text",
|
||||
href: "https://example.com/manage?id=42&lang=fr",
|
||||
linkText: "Se désabonner",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Russian Отписаться in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ru",
|
||||
linkText: "Отписаться",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Chinese 退订 in link text",
|
||||
href: "https://example.com/manage?id=42&lang=zh",
|
||||
linkText: "退订",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Japanese 登録を取り消す in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ja",
|
||||
linkText: "登録を取り消す",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Korean 구독 해지 in link text",
|
||||
href: "https://example.com/manage?id=42&lang=ko",
|
||||
linkText: "구독 해지",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Dutch Uitschrijven in link text",
|
||||
href: "https://example.com/manage?id=42&lang=nl",
|
||||
linkText: "Uitschrijven",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Polish Odsubskrybuj in link text",
|
||||
href: "https://example.com/manage?id=42&lang=pl",
|
||||
linkText: "Odsubskrybuj",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Turkish Üyeliği sonlandır in link text",
|
||||
href: "https://example.com/manage?id=42&lang=tr",
|
||||
linkText: "Üyeliği sonlandır",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
|
@ -214,6 +281,16 @@ 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)
|
||||
|
|
@ -608,453 +685,6 @@ 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) {
|
||||
|
|
@ -1076,3 +706,276 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
241
pkg/analyzer/dns.go
Normal file
241
pkg/analyzer/dns.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// 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 (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// DNSAnalyzer analyzes DNS records for email domains
|
||||
type DNSAnalyzer struct {
|
||||
Timeout time.Duration
|
||||
resolver DNSResolver
|
||||
}
|
||||
|
||||
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
|
||||
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
|
||||
return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver())
|
||||
}
|
||||
|
||||
// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver.
|
||||
// If resolver is nil, a StandardDNSResolver will be used.
|
||||
func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer {
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second // Default timeout
|
||||
}
|
||||
if resolver == nil {
|
||||
resolver = NewStandardDNSResolver()
|
||||
}
|
||||
return &DNSAnalyzer{
|
||||
Timeout: timeout,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// AnalyzeDNS performs DNS validation for the email's domain
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Check BIMI record with default selector
|
||||
results.BimiRecord = d.checkBIMIRecord(domain, "default")
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
|
||||
// Returns a score from 0-100 where higher is better
|
||||
// This version excludes PTR and DKIM checks since they require email context
|
||||
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) {
|
||||
if results == nil {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
score := 0
|
||||
|
||||
// MX Records: 30 points (only one domain to check)
|
||||
mxScore := d.calculateMXScore(results)
|
||||
// Since calculateMXScore checks both From and RP domains,
|
||||
// and we only have From domain, we use the full score
|
||||
score += 30 * mxScore / 100
|
||||
|
||||
// SPF Records: 30 points
|
||||
score += 30 * d.calculateSPFScore(results) / 100
|
||||
|
||||
// DMARC Record: 40 points
|
||||
score += 40 * d.calculateDMARCScore(results) / 100
|
||||
|
||||
// BIMI Record: only bonus
|
||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||
if score >= 100 {
|
||||
return 100, "A+"
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed maximum
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
// Ensure score is non-negative
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
return score, ScoreToGradeKind(score)
|
||||
}
|
||||
|
||||
// CalculateDNSScore calculates the DNS score from records results
|
||||
// Returns a score from 0-100 where higher is better
|
||||
// senderIP is the original sender IP address used for FCrDNS verification
|
||||
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+"
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed maximum
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
// Ensure score is non-negative
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
return score, ScoreToGrade(score)
|
||||
}
|
||||
114
pkg/analyzer/dns_bimi.go
Normal file
114
pkg/analyzer/dns_bimi.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// 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
|
||||
}
|
||||
128
pkg/analyzer/dns_bimi_test.go
Normal file
128
pkg/analyzer/dns_bimi_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
pkg/analyzer/dns_dkim.go
Normal file
116
pkg/analyzer/dns_dkim.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// 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
|
||||
}
|
||||
72
pkg/analyzer/dns_dkim_test.go
Normal file
72
pkg/analyzer/dns_dkim_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
256
pkg/analyzer/dns_dmarc.go
Normal file
256
pkg/analyzer/dns_dmarc.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// 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
|
||||
}
|
||||
343
pkg/analyzer/dns_dmarc_test.go
Normal file
343
pkg/analyzer/dns_dmarc_test.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
94
pkg/analyzer/dns_fcr.go
Normal file
94
pkg/analyzer/dns_fcr.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// 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
|
||||
}
|
||||
115
pkg/analyzer/dns_mx.go
Normal file
115
pkg/analyzer/dns_mx.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// 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
|
||||
}
|
||||
80
pkg/analyzer/dns_resolver.go
Normal file
80
pkg/analyzer/dns_resolver.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// DNSResolver defines the interface for DNS resolution operations.
|
||||
// This interface abstracts DNS lookups to allow for custom implementations,
|
||||
// such as mock resolvers for testing or caching resolvers for performance.
|
||||
type DNSResolver interface {
|
||||
// LookupMX returns the DNS MX records for the given domain.
|
||||
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
|
||||
|
||||
// LookupTXT returns the DNS TXT records for the given domain.
|
||||
LookupTXT(ctx context.Context, name string) ([]string, error)
|
||||
|
||||
// LookupAddr performs a reverse lookup for the given IP address,
|
||||
// returning a list of hostnames mapping to that address.
|
||||
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||
|
||||
// LookupHost looks up the given hostname using the local resolver.
|
||||
// It returns a slice of that host's addresses (IPv4 and IPv6).
|
||||
LookupHost(ctx context.Context, host string) ([]string, error)
|
||||
}
|
||||
|
||||
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver.
|
||||
type StandardDNSResolver struct {
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings.
|
||||
func NewStandardDNSResolver() DNSResolver {
|
||||
return &StandardDNSResolver{
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupMX implements DNSResolver.LookupMX using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
|
||||
return r.resolver.LookupMX(ctx, name)
|
||||
}
|
||||
|
||||
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||
return r.resolver.LookupTXT(ctx, name)
|
||||
}
|
||||
|
||||
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
|
||||
return r.resolver.LookupAddr(ctx, addr)
|
||||
}
|
||||
|
||||
// LookupHost implements DNSResolver.LookupHost using net.Resolver.
|
||||
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
|
||||
return r.resolver.LookupHost(ctx, host)
|
||||
}
|
||||
367
pkg/analyzer/dns_spf.go
Normal file
367
pkg/analyzer/dns_spf.go
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// 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
|
||||
}
|
||||
284
pkg/analyzer/dns_spf_test.go
Normal file
284
pkg/analyzer/dns_spf_test.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
pkg/analyzer/dns_test.go
Normal file
58
pkg/analyzer/dns_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// 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 TestNewDNSAnalyzer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
expectedTimeout time.Duration
|
||||
}{
|
||||
{
|
||||
name: "Default timeout",
|
||||
timeout: 0,
|
||||
expectedTimeout: 10 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "Custom timeout",
|
||||
timeout: 5 * time.Second,
|
||||
expectedTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
analyzer := NewDNSAnalyzer(tt.timeout)
|
||||
if analyzer.Timeout != tt.expectedTimeout {
|
||||
t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout)
|
||||
}
|
||||
if analyzer.resolver == nil {
|
||||
t.Error("Resolver should not be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
696
pkg/analyzer/headers.go
Normal file
696
pkg/analyzer/headers.go
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
// 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
|
||||
}
|
||||
1079
pkg/analyzer/headers_test.go
Normal file
1079
pkg/analyzer/headers_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -28,9 +28,16 @@ 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
|
||||
|
|
@ -211,8 +218,27 @@ 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 {
|
||||
return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||
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
|
||||
}
|
||||
|
||||
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
|
||||
|
|
@ -230,6 +256,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
|
|||
}
|
||||
|
||||
for _, headerName := range saHeaders {
|
||||
if values, ok := e.Header[headerName]; ok && len(values) > 0 {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
headers[headerName] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// GetRspamdHeaders extracts rspamd-related headers
|
||||
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
|
||||
headers := make(map[string]string)
|
||||
|
||||
rspamdHeaders := []string{
|
||||
"X-Spamd-Result",
|
||||
"X-Rspamd-Score",
|
||||
"X-Rspamd-Action",
|
||||
"X-Rspamd-Server",
|
||||
}
|
||||
|
||||
for _, headerName := range rspamdHeaders {
|
||||
if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
|
|
@ -275,3 +328,20 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
|
|||
func (e *EmailMessage) HasHeader(key string) bool {
|
||||
return e.Header.Get(key) != ""
|
||||
}
|
||||
|
||||
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
|
||||
// The header format is: <url1>, <url2>, ...
|
||||
func (e *EmailMessage) GetListUnsubscribeURLs() []string {
|
||||
value := e.Header.Get("List-Unsubscribe")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var urls []string
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") {
|
||||
urls = append(urls, part[1:len(part)-1])
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
|
@ -106,6 +106,9 @@ Content-Type: text/html; charset=utf-8
|
|||
}
|
||||
|
||||
func TestGetAuthenticationResults(t *testing.T) {
|
||||
// Force hostname
|
||||
hostname = "example.com"
|
||||
|
||||
rawEmail := `From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Email
|
||||
346
pkg/analyzer/rbl.go
Normal file
346
pkg/analyzer/rbl.go
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
// DefaultRBLs is a list of commonly used RBL providers
|
||||
var DefaultRBLs = []string{
|
||||
"zen.spamhaus.org", // Spamhaus combined list
|
||||
"bl.spamcop.net", // SpamCop
|
||||
"dnsbl.sorbs.net", // SORBS
|
||||
"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 {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
// DNSListResults represents the results of DNS list checks
|
||||
type DNSListResults struct {
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int // Total listings including informational entries
|
||||
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||
}
|
||||
|
||||
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||
results := &DNSListResults{
|
||||
Checks: make(map[string][]api.BlacklistCheck),
|
||||
}
|
||||
|
||||
ips := r.extractIPs(email)
|
||||
if len(ips) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
results.IPsChecked = ips
|
||||
|
||||
for _, ip := range ips {
|
||||
for _, list := range r.Lists {
|
||||
check := r.checkIP(ip, list)
|
||||
results.Checks[ip] = append(results.Checks[ip], check)
|
||||
if check.Listed {
|
||||
results.ListedCount++
|
||||
if !r.informationalSet[list] {
|
||||
results.RelevantListedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var ips []string
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
receivedHeaders := email.Header["Received"]
|
||||
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`)
|
||||
|
||||
for _, received := range receivedHeaders {
|
||||
matches := ipv4Pattern.FindAllString(received, -1)
|
||||
for _, match := range matches {
|
||||
if !r.isPublicIP(match) {
|
||||
continue
|
||||
}
|
||||
if !seenIPs[match] {
|
||||
ips = append(ips, match)
|
||||
seenIPs[match] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
originatingIP := email.Header.Get("X-Originating-IP")
|
||||
if originatingIP != "" {
|
||||
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||
cleanIP = strings.TrimSpace(cleanIP)
|
||||
matches := ipv4Pattern.FindString(cleanIP)
|
||||
if matches != "" && r.isPublicIP(matches) {
|
||||
ips = append(ips, matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips
|
||||
}
|
||||
|
||||
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
reversedIP := r.reverseIP(ip)
|
||||
if reversedIP == "" {
|
||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||
return check
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("%s.%s", reversedIP, list)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||
if err != nil {
|
||||
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))
|
||||
return check
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
|
||||
// Example: 192.0.2.1 -> 1.2.0.192
|
||||
func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ipv4 := ip.To4()
|
||||
if ipv4 == nil {
|
||||
return "" // IPv6 not supported yet
|
||||
}
|
||||
|
||||
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) {
|
||||
if results == nil || len(results.IPsChecked) == 0 {
|
||||
return 100, ""
|
||||
}
|
||||
|
||||
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
||||
if scoringListCount <= 0 {
|
||||
return 100, "A+"
|
||||
}
|
||||
|
||||
percentage := 100 - results.RelevantListedCount*100/scoringListCount
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
|
||||
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
||||
var listedIPs []string
|
||||
|
||||
for ip, checks := range results.Checks {
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
listedIPs = append(listedIPs, ip)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return listedIPs
|
||||
}
|
||||
|
||||
// GetListsForIP returns all lists that match a specific IP
|
||||
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
|
||||
var lists []string
|
||||
|
||||
if checks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range checks {
|
||||
if check.Listed {
|
||||
lists = append(lists, check.Rbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lists
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ package analyzer
|
|||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -56,12 +55,12 @@ func TestNewRBLChecker(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checker := NewRBLChecker(tt.timeout, tt.rbls)
|
||||
checker := NewRBLChecker(tt.timeout, tt.rbls, false)
|
||||
if checker.Timeout != tt.expectedTimeout {
|
||||
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||
}
|
||||
if len(checker.RBLs) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
||||
if len(checker.Lists) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
|
||||
}
|
||||
if checker.resolver == nil {
|
||||
t.Error("Resolver should not be nil")
|
||||
|
|
@ -98,7 +97,7 @@ func TestReverseIP(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -158,7 +157,7 @@ func TestIsPublicIP(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -238,7 +237,7 @@ func TestExtractIPs(t *testing.T) {
|
|||
},*/
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -266,68 +265,68 @@ func TestExtractIPs(t *testing.T) {
|
|||
func TestGetBlacklistScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *RBLResults
|
||||
expectedScore float32
|
||||
results *DNSListResults
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
expectedScore: 2.0,
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "No IPs checked",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{},
|
||||
},
|
||||
expectedScore: 2.0,
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Not listed on any RBL",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
},
|
||||
expectedScore: 2.0,
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
},
|
||||
expectedScore: 1.0,
|
||||
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
|
||||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
},
|
||||
expectedScore: 0.5,
|
||||
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
|
||||
},
|
||||
{
|
||||
name: "Listed on 3 RBLs",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 3,
|
||||
},
|
||||
expectedScore: 0.5,
|
||||
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
|
||||
},
|
||||
{
|
||||
name: "Listed on 4+ RBLs",
|
||||
results: &RBLResults{
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
},
|
||||
expectedScore: 0.0,
|
||||
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := checker.GetBlacklistScore(tt.results)
|
||||
score, _ := checker.CalculateScore(tt.results)
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
|
|
@ -335,215 +334,24 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
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),
|
||||
},
|
||||
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)
|
||||
|
||||
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.Critical,
|
||||
},
|
||||
{
|
||||
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.High,
|
||||
},
|
||||
{
|
||||
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.High,
|
||||
},
|
||||
}
|
||||
|
||||
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},
|
||||
results := &DNSListResults{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: true},
|
||||
},
|
||||
"198.51.100.2": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: false},
|
||||
},
|
||||
"198.51.100.3": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
listedIPs := checker.GetUniqueListedIPs(results)
|
||||
|
||||
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
|
||||
|
|
@ -555,16 +363,20 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetRBLsForIP(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.1", RBL: "dnsbl.sorbs.net", Listed: false},
|
||||
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
|
||||
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},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
checker := NewRBLChecker(5*time.Second, nil, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -590,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rbls := checker.GetRBLsForIP(results, tt.ip)
|
||||
rbls := checker.GetListsForIP(results, tt.ip)
|
||||
|
||||
if len(rbls) != len(tt.expectedRBLs) {
|
||||
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||
306
pkg/analyzer/report.go
Normal file
306
pkg/analyzer/report.go
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// 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 (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ReportGenerator generates comprehensive deliverability reports
|
||||
type ReportGenerator struct {
|
||||
authAnalyzer *AuthenticationAnalyzer
|
||||
spamAnalyzer *SpamAssassinAnalyzer
|
||||
rspamdAnalyzer *RspamdAnalyzer
|
||||
dnsAnalyzer *DNSAnalyzer
|
||||
rblChecker *DNSListChecker
|
||||
dnswlChecker *DNSListChecker
|
||||
contentAnalyzer *ContentAnalyzer
|
||||
headerAnalyzer *HeaderAnalyzer
|
||||
}
|
||||
|
||||
// NewReportGenerator creates a new report generator
|
||||
func NewReportGenerator(
|
||||
dnsTimeout time.Duration,
|
||||
httpTimeout time.Duration,
|
||||
rbls []string,
|
||||
dnswls []string,
|
||||
checkAllIPs bool,
|
||||
) *ReportGenerator {
|
||||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||
headerAnalyzer: NewHeaderAnalyzer(),
|
||||
}
|
||||
}
|
||||
|
||||
// AnalysisResults contains all intermediate analysis results
|
||||
type AnalysisResults struct {
|
||||
Email *EmailMessage
|
||||
Authentication *api.AuthenticationResults
|
||||
Content *ContentResults
|
||||
DNS *api.DNSResults
|
||||
Headers *api.HeaderAnalysis
|
||||
RBL *DNSListResults
|
||||
DNSWL *DNSListResults
|
||||
SpamAssassin *api.SpamAssassinResult
|
||||
Rspamd *api.RspamdResult
|
||||
}
|
||||
|
||||
// AnalyzeEmail performs complete email analysis
|
||||
func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||
results := &AnalysisResults{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
// 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.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// GenerateReport creates a complete API report from analysis results
|
||||
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
|
||||
reportID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
report := &api.Report{
|
||||
Id: utils.UUIDToBase32(reportID),
|
||||
TestId: utils.UUIDToBase32(testID),
|
||||
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)
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
// Add authentication results
|
||||
report.Authentication = results.Authentication
|
||||
|
||||
// Add content analysis
|
||||
if results.Content != nil {
|
||||
contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
|
||||
report.ContentAnalysis = contentAnalysis
|
||||
}
|
||||
|
||||
// Add DNS records
|
||||
if results.DNS != nil {
|
||||
report.DnsResults = results.DNS
|
||||
}
|
||||
|
||||
// Add headers results
|
||||
report.HeaderAnalysis = results.Headers
|
||||
|
||||
// Add blacklist checks as a map of IP -> array of BlacklistCheck
|
||||
if results.RBL != nil && len(results.RBL.Checks) > 0 {
|
||||
report.Blacklists = &results.RBL.Checks
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// GenerateRawEmail returns the raw email message as a string
|
||||
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
|
||||
if email == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := email.RawHeaders
|
||||
if email.RawBody != "" {
|
||||
raw += "\n" + email.RawBody
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
228
pkg/analyzer/report_test.go
Normal file
228
pkg/analyzer/report_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// 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"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNewReportGenerator(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
if gen == nil {
|
||||
t.Fatal("Expected report generator, got nil")
|
||||
}
|
||||
|
||||
if gen.authAnalyzer == nil {
|
||||
t.Error("authAnalyzer should not be nil")
|
||||
}
|
||||
if gen.spamAnalyzer == nil {
|
||||
t.Error("spamAnalyzer should not be nil")
|
||||
}
|
||||
if gen.dnsAnalyzer == nil {
|
||||
t.Error("dnsAnalyzer should not be nil")
|
||||
}
|
||||
if gen.rblChecker == nil {
|
||||
t.Error("rblChecker should not be nil")
|
||||
}
|
||||
if gen.contentAnalyzer == nil {
|
||||
t.Error("contentAnalyzer should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEmail(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
|
||||
email := createTestEmail()
|
||||
|
||||
results := gen.AnalyzeEmail(email)
|
||||
|
||||
if results == nil {
|
||||
t.Fatal("Expected analysis results, got nil")
|
||||
}
|
||||
|
||||
if results.Email == nil {
|
||||
t.Error("Email should not be nil")
|
||||
}
|
||||
|
||||
if results.Authentication == nil {
|
||||
t.Error("Authentication should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReport(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmail()
|
||||
results := gen.AnalyzeEmail(email)
|
||||
|
||||
report := gen.GenerateReport(testID, results)
|
||||
|
||||
if report == nil {
|
||||
t.Fatal("Expected report, got nil")
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if report.Id == "" {
|
||||
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.Score < 0 || report.Score > 100 {
|
||||
t.Errorf("Score %v is out of bounds", report.Score)
|
||||
}
|
||||
|
||||
if report.Summary == nil {
|
||||
t.Error("Summary should not be nil")
|
||||
}
|
||||
|
||||
// Verify score summary (all scores are 0-100 percentages)
|
||||
if report.Summary != nil {
|
||||
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 {
|
||||
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
|
||||
}
|
||||
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 {
|
||||
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
|
||||
}
|
||||
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 {
|
||||
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
|
||||
}
|
||||
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 {
|
||||
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
|
||||
}
|
||||
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
results := gen.AnalyzeEmail(email)
|
||||
|
||||
report := gen.GenerateReport(testID, results)
|
||||
|
||||
if report.Spamassassin == nil {
|
||||
t.Error("SpamAssassin result should not be nil")
|
||||
}
|
||||
|
||||
if report.Spamassassin != nil {
|
||||
if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 {
|
||||
t.Error("SpamAssassin scores should be set")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRawEmail(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Nil email",
|
||||
email: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Email with headers only",
|
||||
email: &EmailMessage{
|
||||
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n",
|
||||
RawBody: "",
|
||||
},
|
||||
expected: "From: sender@example.com\nTo: recipient@example.com\n",
|
||||
},
|
||||
{
|
||||
name: "Email with headers and body",
|
||||
email: &EmailMessage{
|
||||
RawHeaders: "From: sender@example.com\n",
|
||||
RawBody: "This is the email body",
|
||||
},
|
||||
expected: "From: sender@example.com\n\nThis is the email body",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := gen.GenerateRawEmail(tt.email)
|
||||
if raw != tt.expected {
|
||||
t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestEmail() *EmailMessage {
|
||||
header := make(mail.Header)
|
||||
header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"}
|
||||
header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{"<test123@example.com>"}
|
||||
|
||||
return &EmailMessage{
|
||||
Header: header,
|
||||
From: &mail.Address{Address: "sender@example.com"},
|
||||
To: []*mail.Address{{Address: "recipient@example.com"}},
|
||||
Subject: "Test Email",
|
||||
MessageID: "<test123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{
|
||||
{
|
||||
ContentType: "text/plain",
|
||||
Content: "This is a test email",
|
||||
IsText: true,
|
||||
},
|
||||
},
|
||||
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: <test123@example.com>\n",
|
||||
RawBody: "This is a test email",
|
||||
}
|
||||
}
|
||||
|
||||
func createTestEmailWithSpamAssassin() *EmailMessage {
|
||||
email := createTestEmail()
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"}
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"}
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
|
||||
return email
|
||||
}
|
||||
155
pkg/analyzer/rspamd.go
Normal file
155
pkg/analyzer/rspamd.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||
const (
|
||||
rspamdDefaultRejectThreshold float32 = 15
|
||||
rspamdDefaultAddHeaderThreshold float32 = 6
|
||||
)
|
||||
|
||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||
type RspamdAnalyzer struct{}
|
||||
|
||||
// NewRspamdAnalyzer creates a new rspamd analyzer
|
||||
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
||||
return &RspamdAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||
headers := email.GetRspamdHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
|
||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
||||
result.Report = &report
|
||||
a.parseSpamdResult(spamdResult, result)
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Score as override/fallback for score
|
||||
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
|
||||
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Server
|
||||
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
|
||||
server := strings.TrimSpace(serverHeader)
|
||||
result.Server = &server
|
||||
}
|
||||
|
||||
// Derive IsSpam from score vs reject threshold.
|
||||
if result.Threshold > 0 {
|
||||
result.IsSpam = result.Score >= result.Threshold
|
||||
} else {
|
||||
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseSpamdResult parses the X-Spamd-Result header
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
||||
// Extract score and threshold from the first line
|
||||
// e.g. "default: False [-3.91 / 15.00]"
|
||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
|
||||
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
|
||||
result.Threshold = float32(threshold)
|
||||
|
||||
// No threshold? use default AddHeaderThreshold
|
||||
if result.Threshold <= 0 {
|
||||
result.Threshold = rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
|
||||
firstLine := strings.SplitN(header, ";", 2)[0]
|
||||
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
|
||||
result.IsSpam = true
|
||||
}
|
||||
|
||||
// Parse symbols: SYMBOL(score)[params]
|
||||
// Each symbol entry is separated by ";", so within each part we use a
|
||||
// greedy match to capture params that may contain nested brackets.
|
||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
matches := symbolRe.FindStringSubmatch(part)
|
||||
if len(matches) > 2 {
|
||||
name := matches[1]
|
||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||
sym := api.RspamdSymbol{
|
||||
Name: name,
|
||||
Score: float32(score),
|
||||
}
|
||||
if len(matches) > 3 && matches[3] != "" {
|
||||
params := matches[3]
|
||||
sym.Params = ¶ms
|
||||
}
|
||||
result.Symbols[name] = sym
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
||||
if result == nil {
|
||||
return 100, "" // rspamd not installed
|
||||
}
|
||||
|
||||
threshold := result.Threshold
|
||||
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
|
||||
|
||||
if percentage > 100 {
|
||||
return 100, "A+"
|
||||
} else if percentage < 0 {
|
||||
return 0, "F"
|
||||
}
|
||||
|
||||
// Linear scale between 0 and threshold
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
414
pkg/analyzer/rspamd_test.go
Normal file
414
pkg/analyzer/rspamd_test.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpamdResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedSymbols map[string]float32
|
||||
expectedSymParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Clean email negative score",
|
||||
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"DATE_IN_PAST": 0.10,
|
||||
"ALL_TRUSTED": -1.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ALL_TRUSTED": "trusted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Spam email True flag",
|
||||
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
||||
expectedScore: 16.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{
|
||||
"BAYES_99": 5.00,
|
||||
"SPOOFED_SENDER": 3.50,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"BAYES_99": "1.00",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold uses default",
|
||||
header: "default: False [1.00 / 0.00]",
|
||||
expectedScore: 1.00,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Symbol without params",
|
||||
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
||||
expectedScore: 2.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"MISSING_DATE": 1.00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Case-insensitive true flag",
|
||||
header: "default: true [8.00 / 6.00]",
|
||||
expectedScore: 8.00,
|
||||
expectedThreshold: 6.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold with symbols containing nested brackets in params",
|
||||
header: "default: False [0.90 / 0.00];\n" +
|
||||
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
|
||||
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
|
||||
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
|
||||
expectedScore: 0.90,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"ARC_REJECT": 1.00,
|
||||
"MIME_GOOD": -0.10,
|
||||
"MIME_TRACE": 0.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
|
||||
"MIME_GOOD": "multipart/alternative,text/plain",
|
||||
"MIME_TRACE": "0:+,1:+,2:~",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
analyzer.parseSpamdResult(tt.header, result)
|
||||
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
for symName, expectedScore := range tt.expectedSymbols {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Score != expectedScore {
|
||||
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
||||
}
|
||||
}
|
||||
for symName, expectedParam := range tt.expectedSymParams {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found for params check", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Params == nil {
|
||||
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
||||
} else if *sym.Params != expectedParam {
|
||||
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRspamd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedServer *string
|
||||
expectedSymCount int
|
||||
}{
|
||||
{
|
||||
name: "Full headers clean email",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
||||
"X-Rspamd-Score": "-3.91",
|
||||
"X-Rspamd-Server": "mail.example.com",
|
||||
},
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "X-Rspamd-Score overrides spamd result score",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
||||
"X-Rspamd-Score": "3.50",
|
||||
},
|
||||
expectedScore: 3.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "Spam email above threshold",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
||||
"X-Rspamd-Score": "16.00",
|
||||
},
|
||||
expectedScore: 16.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "2.00",
|
||||
},
|
||||
expectedScore: 2.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "7.00",
|
||||
},
|
||||
expectedScore: 7.00,
|
||||
expectedIsSpam: true,
|
||||
},
|
||||
{
|
||||
name: "Server header is trimmed",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "1.00",
|
||||
"X-Rspamd-Server": " rspamd-01 ",
|
||||
},
|
||||
expectedScore: 1.00,
|
||||
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
for k, v := range tt.headers {
|
||||
email.Header[k] = []string{v}
|
||||
}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
if tt.expectedServer != nil {
|
||||
if result.Server == nil {
|
||||
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
||||
} else if *result.Server != *tt.expectedServer {
|
||||
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
||||
}
|
||||
}
|
||||
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
||||
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRspamdScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *api.RspamdResult
|
||||
expectedScore int
|
||||
expectedGrade string
|
||||
}{
|
||||
{
|
||||
name: "Nil result (rspamd not installed)",
|
||||
result: nil,
|
||||
expectedScore: 100,
|
||||
expectedGrade: "",
|
||||
},
|
||||
{
|
||||
name: "Score well below threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: -3.91,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A+",
|
||||
},
|
||||
{
|
||||
name: "Score at zero",
|
||||
result: &api.RspamdResult{
|
||||
Score: 0,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A",
|
||||
},
|
||||
{
|
||||
name: "Score at threshold (half of 2*threshold)",
|
||||
result: &api.RspamdResult{
|
||||
Score: 15.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
||||
expectedScore: 50,
|
||||
},
|
||||
{
|
||||
name: "Score above 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 31.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
{
|
||||
name: "Score exactly at 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 30.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(30*100/30) = 100 - 100 = 0
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
||||
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
||||
BAYES_HAM(-3.00)[99%];
|
||||
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
||||
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
||||
FROM_HAS_DN(0.00)[];
|
||||
MIME_GOOD(-0.10)[text/plain];
|
||||
X-Rspamd-Score: -3.91
|
||||
X-Rspamd-Server: rspamd-01.example.com
|
||||
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
||||
From: sender@example.com
|
||||
To: test@happydomain.org
|
||||
Subject: Test email
|
||||
Message-ID: <test123@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain
|
||||
|
||||
Hello world`
|
||||
|
||||
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.IsSpam {
|
||||
t.Error("Expected IsSpam=false")
|
||||
}
|
||||
if result.Score != -3.91 {
|
||||
t.Errorf("Score = %v, want -3.91", result.Score)
|
||||
}
|
||||
if result.Threshold != 15.00 {
|
||||
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
||||
}
|
||||
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
||||
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
||||
}
|
||||
|
||||
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
||||
for _, sym := range expectedSymbols {
|
||||
if _, ok := result.Symbols[sym]; !ok {
|
||||
t.Errorf("Symbol %s not found", sym)
|
||||
}
|
||||
}
|
||||
|
||||
score, _ := analyzer.CalculateRspamdScore(result)
|
||||
if score != 100 {
|
||||
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
||||
}
|
||||
}
|
||||
|
||||
99
pkg/analyzer/scoring.go
Normal file
99
pkg/analyzer/scoring.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// 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 (
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
||||
func ScoreToGrade(score int) 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"
|
||||
default:
|
||||
return "F"
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation
|
||||
func ScoreToGradeKind(score int) string {
|
||||
switch {
|
||||
case score > 100:
|
||||
return "A+"
|
||||
case score >= 90:
|
||||
return "A"
|
||||
case score >= 80:
|
||||
return "B"
|
||||
case score >= 60:
|
||||
return "C"
|
||||
case score >= 45:
|
||||
return "D"
|
||||
case score >= 30:
|
||||
return "E"
|
||||
default:
|
||||
return "F"
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
|
||||
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||
return api.ReportGrade(ScoreToGrade(score))
|
||||
}
|
||||
|
||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||
func gradeRank(grade string) int {
|
||||
switch grade {
|
||||
case "A+":
|
||||
return 6
|
||||
case "A":
|
||||
return 5
|
||||
case "B":
|
||||
return 4
|
||||
case "C":
|
||||
return 3
|
||||
case "D":
|
||||
return 2
|
||||
case "E":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MinGrade returns the minimal (worse) grade between the two given grades
|
||||
func MinGrade(a, b string) string {
|
||||
if gradeRank(a) <= gradeRank(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
220
pkg/analyzer/spamassassin.go
Normal file
220
pkg/analyzer/spamassassin.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// 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 (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
||||
type SpamAssassinAnalyzer struct{}
|
||||
|
||||
// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer
|
||||
func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
|
||||
return &SpamAssassinAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
||||
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
|
||||
headers := email.GetSpamAssassinHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
}
|
||||
|
||||
// Parse X-Spam-Status header
|
||||
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse X-Spam-Flag header (as fallback)
|
||||
if flagHeader, ok := headers["X-Spam-Flag"]; ok {
|
||||
result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES"
|
||||
}
|
||||
|
||||
// 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))
|
||||
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))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Check if spam (first word)
|
||||
parts := strings.SplitN(header, ",", 2)
|
||||
if len(parts) > 0 {
|
||||
firstPart := strings.TrimSpace(parts[0])
|
||||
result.IsSpam = strings.EqualFold(firstPart, "yes")
|
||||
}
|
||||
|
||||
// Extract score
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract required score
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tests
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpamReport parses the X-Spam-Report header to extract test details
|
||||
// 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) {
|
||||
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 == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if result == nil {
|
||||
return 100, "" // No spam scan results, assume good
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -34,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) {
|
|||
name string
|
||||
header string
|
||||
expectedIsSpam bool
|
||||
expectedScore float64
|
||||
expectedReq float64
|
||||
expectedScore float32
|
||||
expectedReq float32
|
||||
expectedTests []string
|
||||
}{
|
||||
{
|
||||
|
|
@ -76,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &SpamAssassinResult{
|
||||
TestDetails: make(map[string]SpamTestDetail),
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
}
|
||||
analyzer.parseSpamStatus(tt.header, result)
|
||||
|
||||
|
|
@ -90,8 +91,12 @@ 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 && !stringSliceEqual(result.Tests, tt.expectedTests) {
|
||||
t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -110,27 +115,27 @@ func TestParseSpamReport(t *testing.T) {
|
|||
`
|
||||
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
result := &SpamAssassinResult{
|
||||
TestDetails: make(map[string]SpamTestDetail),
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
}
|
||||
|
||||
analyzer.parseSpamReport(report, result)
|
||||
|
||||
expectedTests := map[string]SpamTestDetail{
|
||||
expectedTests := map[string]api.SpamTestDetail{
|
||||
"BAYES_99": {
|
||||
Name: "BAYES_99",
|
||||
Score: 5.0,
|
||||
Description: "Bayes spam probability is 99 to 100%",
|
||||
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
|
||||
},
|
||||
"SPOOFED_SENDER": {
|
||||
Name: "SPOOFED_SENDER",
|
||||
Score: 3.5,
|
||||
Description: "From address doesn't match envelope sender",
|
||||
Description: api.PtrTo("From address doesn't match envelope sender"),
|
||||
},
|
||||
"ALL_TRUSTED": {
|
||||
Name: "ALL_TRUSTED",
|
||||
Score: -1.0,
|
||||
Description: "All mail servers are trusted",
|
||||
Description: api.PtrTo("All mail servers are trusted"),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -143,8 +148,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,56 +157,63 @@ func TestParseSpamReport(t *testing.T) {
|
|||
func TestGetSpamAssassinScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *SpamAssassinResult
|
||||
expectedScore float32
|
||||
minScore float32
|
||||
maxScore float32
|
||||
result *api.SpamAssassinResult
|
||||
expectedScore int
|
||||
minScore int
|
||||
maxScore int
|
||||
}{
|
||||
{
|
||||
name: "Nil result",
|
||||
result: nil,
|
||||
expectedScore: 0.0,
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Excellent score (negative)",
|
||||
result: &SpamAssassinResult{
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: -2.5,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 2.0,
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Good score (below threshold)",
|
||||
result: &SpamAssassinResult{
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: 2.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
minScore: 1.5,
|
||||
maxScore: 2.0,
|
||||
expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60
|
||||
},
|
||||
{
|
||||
name: "Borderline (just above threshold)",
|
||||
result: &SpamAssassinResult{
|
||||
name: "Score at threshold",
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: 5.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
},
|
||||
{
|
||||
name: "Above threshold (spam)",
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: 6.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 1.0,
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
},
|
||||
{
|
||||
name: "High spam score",
|
||||
result: &SpamAssassinResult{
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: 12.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0.5,
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
},
|
||||
{
|
||||
name: "Very high spam score",
|
||||
result: &SpamAssassinResult{
|
||||
result: &api.SpamAssassinResult{
|
||||
Score: 20.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
expectedScore: 0.0,
|
||||
expectedScore: 0, // >= threshold = 0
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +221,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := analyzer.GetSpamAssassinScore(tt.result)
|
||||
score, _ := analyzer.CalculateSpamAssassinScore(tt.result)
|
||||
|
||||
if tt.minScore > 0 || tt.maxScore > 0 {
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
|
|
@ -229,7 +241,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
|
|||
name string
|
||||
headers map[string]string
|
||||
expectedIsSpam bool
|
||||
expectedScore float64
|
||||
expectedScore float32
|
||||
expectedHasDetails bool
|
||||
}{
|
||||
{
|
||||
|
|
@ -295,86 +307,6 @@ 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{
|
||||
|
|
@ -388,95 +320,147 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateMainSpamCheck(t *testing.T) {
|
||||
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,
|
||||
RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED,
|
||||
SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1
|
||||
X-Spam-Level:
|
||||
X-Spam-Report:
|
||||
* 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
|
||||
* to Validity was blocked. See
|
||||
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
||||
* more information.
|
||||
* [80.67.179.207 listed in sa-accredit.habeas.com]
|
||||
* 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
|
||||
* to Validity was blocked. See
|
||||
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
||||
* more information.
|
||||
* [80.67.179.207 listed in bl.score.senderscore.com]
|
||||
* 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The
|
||||
* query to Validity was blocked. See
|
||||
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
||||
* more information.
|
||||
* [80.67.179.207 listed in sa-trusted.bondedsender.org]
|
||||
* -0.0 SPF_PASS SPF: sender matches SPF record
|
||||
* 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record
|
||||
* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
|
||||
* 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
|
||||
* valid
|
||||
* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
|
||||
* domain
|
||||
Date: Sun, 19 Oct 2025 08:37:30 +0000
|
||||
Message-ID: <aPSjR57mUnCAt7sp@happydomain.org>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Disposition: inline
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
BODY`
|
||||
|
||||
// TestAnalyzeRealEmailExample tests the analyzer with the real example email file
|
||||
func TestAnalyzeRealEmailExample(t *testing.T) {
|
||||
// Parse the email using the standard net/mail package
|
||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
// Create analyzer and analyze SpamAssassin headers
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
result := analyzer.AnalyzeSpamAssassin(email)
|
||||
|
||||
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},
|
||||
// Validate that we got a result
|
||||
if result == nil {
|
||||
t.Fatal("Expected SpamAssassin result, got nil")
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
// Validate IsSpam flag (should be false for this email)
|
||||
if result.IsSpam {
|
||||
t.Error("IsSpam should be false for real_example.eml")
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateTestCheck(tt.detail)
|
||||
// Validate score (should be -0.1)
|
||||
var expectedScore float32 = -0.1
|
||||
if result.Score != expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
// Validate required score (should be 5.0)
|
||||
var expectedRequired float32 = 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)
|
||||
}
|
||||
|
||||
// Validate that tests were extracted
|
||||
if len(*result.Tests) == 0 {
|
||||
t.Error("Expected tests to be extracted, got none")
|
||||
}
|
||||
|
||||
// Check for expected tests from the real email
|
||||
expectedTests := map[string]bool{
|
||||
"DKIM_SIGNED": true,
|
||||
"DKIM_VALID": true,
|
||||
"DKIM_VALID_AU": true,
|
||||
"SPF_PASS": true,
|
||||
"SPF_HELO_NONE": true,
|
||||
}
|
||||
|
||||
for _, testName := range *result.Tests {
|
||||
if expectedTests[testName] {
|
||||
t.Logf("Found expected test: %s", testName)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that test details were parsed from X-Spam-Report
|
||||
if len(result.TestDetails) == 0 {
|
||||
t.Error("Expected test details to be parsed from X-Spam-Report, got none")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Define expected test details with their scores
|
||||
expectedTestDetails := map[string]float32{
|
||||
"SPF_PASS": -0.0,
|
||||
"SPF_HELO_NONE": 0.0,
|
||||
"DKIM_VALID": -0.1,
|
||||
"DKIM_SIGNED": 0.1,
|
||||
"DKIM_VALID_AU": -0.1,
|
||||
"RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0,
|
||||
"RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0,
|
||||
"RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0,
|
||||
}
|
||||
|
||||
// Iterate over expected tests and verify they exist in TestDetails
|
||||
for testName, expectedScore := range expectedTestDetails {
|
||||
detail, ok := result.TestDetails[testName]
|
||||
if !ok {
|
||||
t.Errorf("Expected test %s not found in TestDetails", testName)
|
||||
continue
|
||||
}
|
||||
if detail.Score != expectedScore {
|
||||
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
|
||||
}
|
||||
if detail.Description == nil || *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)
|
||||
}
|
||||
}
|
||||
|
||||
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