diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c3e1579
--- /dev/null
+++ b/.dockerignore
@@ -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/
diff --git a/.drone-manifest.yml b/.drone-manifest.yml
new file mode 100644
index 0000000..4984d45
--- /dev/null
+++ b/.drone-manifest.yml
@@ -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
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..779952f
--- /dev/null
+++ b/.drone.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 223cf99..7ece05e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,9 @@ vendor/
.env.local
*.local
+# Logs files
+logs/
+
# Database files
*.db
*.sqlite
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9626813
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3c213cd
--- /dev/null
+++ b/README.md
@@ -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-@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).
+
+[
](https://nlnet.nl)
+[
](https://nlnet.nl/core)
+
+## License
+
+GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
diff --git a/api/openapi.yaml b/api/openapi.yaml
index f027f1a..e989261 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -31,11 +31,11 @@ paths:
tags:
- tests
summary: Create a new deliverability test
- description: Generates a unique test email address for sending test emails
+ description: Generates a unique test email address for sending test emails. No database record is created until an email is received.
operationId: createTest
responses:
'201':
- description: Test created successfully
+ description: Test email address generated successfully
content:
application/json:
schema:
@@ -51,8 +51,8 @@ paths:
get:
tags:
- tests
- summary: Get test metadata
- description: Retrieve test status and metadata
+ summary: Get test status
+ description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available.
operationId: getTest
parameters:
- name: id
@@ -60,16 +60,17 @@ paths:
required: true
schema:
type: string
- format: uuid
+ pattern: '^[a-z0-9-]+$'
+ description: Base32-encoded test ID (with hyphens)
responses:
'200':
- description: Test metadata retrieved successfully
+ description: Test status retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Test'
- '404':
- description: Test not found
+ '500':
+ description: Internal server error
content:
application/json:
schema:
@@ -88,7 +89,8 @@ paths:
required: true
schema:
type: string
- format: uuid
+ pattern: '^[a-z0-9-]+$'
+ description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Report retrieved successfully
@@ -116,7 +118,8 @@ paths:
required: true
schema:
type: string
- format: uuid
+ pattern: '^[a-z0-9-]+$'
+ description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Raw email retrieved successfully
@@ -131,6 +134,107 @@ paths:
schema:
$ref: '#/components/schemas/Error'
+ /report/{id}/reanalyze:
+ post:
+ tags:
+ - reports
+ summary: Reanalyze email and regenerate report
+ description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes.
+ operationId: reanalyzeReport
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ pattern: '^[a-z0-9-]+$'
+ description: Base32-encoded test ID (with hyphens)
+ responses:
+ '200':
+ description: Report regenerated successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Report'
+ '404':
+ description: Email not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error during reanalysis
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /domain:
+ post:
+ tags:
+ - tests
+ summary: Test a domain's email configuration
+ description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately.
+ operationId: testDomain
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DomainTestRequest'
+ responses:
+ '200':
+ description: Domain test completed successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DomainTestResponse'
+ '400':
+ description: Invalid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /blacklist:
+ post:
+ tags:
+ - tests
+ summary: Check an IP address against DNS blacklists
+ description: Tests a single IP address (IPv4 or IPv6) against configured DNS-based blacklists (RBLs) without requiring an actual email to be sent. Returns results immediately.
+ operationId: checkBlacklist
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BlacklistCheckRequest'
+ responses:
+ '200':
+ description: Blacklist check completed successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BlacklistCheckResponse'
+ '400':
+ description: Invalid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
/status:
get:
tags:
@@ -154,31 +258,22 @@ components:
- id
- email
- status
- - created_at
properties:
id:
type: string
- format: uuid
- description: Unique test identifier
- example: "550e8400-e29b-41d4-a716-446655440000"
+ pattern: '^[a-z0-9-]+$'
+ description: Unique test identifier (base32-encoded with hyphens)
+ example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a"
email:
type: string
format: email
description: Unique test email address
- example: "test-550e8400@example.com"
+ example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
status:
type: string
- enum: [pending, received, analyzed, failed]
- description: Current test status
+ enum: [pending, analyzed]
+ description: Current test status (pending = no report yet, analyzed = report available)
example: "analyzed"
- created_at:
- type: string
- format: date-time
- description: Test creation timestamp
- updated_at:
- type: string
- format: date-time
- description: Last update timestamp
TestResponse:
type: object
@@ -189,12 +284,13 @@ components:
properties:
id:
type: string
- format: uuid
- example: "550e8400-e29b-41d4-a716-446655440000"
+ pattern: '^[a-z0-9-]+$'
+ description: Unique test identifier (base32-encoded with hyphens)
+ example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a"
email:
type: string
format: email
- example: "test-550e8400@example.com"
+ example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
status:
type: string
enum: [pending]
@@ -209,42 +305,68 @@ components:
- id
- test_id
- score
- - checks
+ - grade
- created_at
properties:
id:
type: string
- format: uuid
- description: Report identifier
+ pattern: '^[a-z0-9-]+$'
+ description: Report identifier (base32-encoded with hyphens)
test_id:
type: string
- format: uuid
- description: Associated test ID
+ pattern: '^[a-z0-9-]+$'
+ description: Associated test ID (base32-encoded with hyphens)
score:
- type: number
- format: float
+ type: integer
minimum: 0
- maximum: 10
- description: Overall deliverability score (0-10)
- example: 8.5
+ maximum: 100
+ description: Overall deliverability score as percentage (0-100)
+ example: 85
+ grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
summary:
$ref: '#/components/schemas/ScoreSummary'
- checks:
- type: array
- items:
- $ref: '#/components/schemas/Check'
authentication:
$ref: '#/components/schemas/AuthenticationResults'
spamassassin:
$ref: '#/components/schemas/SpamAssassinResult'
- dns_records:
- type: array
- items:
- $ref: '#/components/schemas/DNSRecord'
+ rspamd:
+ $ref: '#/components/schemas/RspamdResult'
+ dns_results:
+ $ref: '#/components/schemas/DNSResults'
blacklists:
- type: array
- items:
- $ref: '#/components/schemas/BlacklistCheck'
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/BlacklistCheck'
+ description: Map of IP addresses to their blacklist check results (array of checks per IP)
+ example:
+ "192.0.2.1":
+ - rbl: "zen.spamhaus.org"
+ listed: false
+ - rbl: "bl.spamcop.net"
+ listed: false
+ whitelists:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/BlacklistCheck'
+ description: Map of IP addresses to their DNS whitelist check results (informational only)
+ example:
+ "192.0.2.1":
+ - rbl: "list.dnswl.org"
+ listed: false
+ - rbl: "swl.spamhaus.org"
+ listed: false
+ content_analysis:
+ $ref: '#/components/schemas/ContentAnalysis'
+ header_analysis:
+ $ref: '#/components/schemas/HeaderAnalysis'
raw_headers:
type: string
description: Raw email headers
@@ -255,92 +377,386 @@ components:
ScoreSummary:
type: object
required:
+ - dns_score
+ - dns_grade
- authentication_score
+ - authentication_grade
- spam_score
+ - spam_grade
- blacklist_score
- - content_score
+ - blacklist_grade
- header_score
+ - header_grade
+ - content_score
+ - content_grade
properties:
+ dns_score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: DNS records score (in percentage)
+ example: 42
+ dns_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
authentication_score:
- type: number
- format: float
+ type: integer
minimum: 0
- maximum: 3
- description: SPF/DKIM/DMARC score (max 3 pts)
- example: 2.8
+ maximum: 100
+ description: SPF/DKIM/DMARC score (in percentage)
+ example: 28
+ authentication_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
spam_score:
- type: number
- format: float
+ type: integer
minimum: 0
- maximum: 2
- description: SpamAssassin score (max 2 pts)
- example: 1.5
+ maximum: 100
+ description: Spam filter score (SpamAssassin + rspamd combined, in percentage)
+ example: 15
+ spam_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
blacklist_score:
- type: number
- format: float
+ type: integer
minimum: 0
- maximum: 2
- description: Blacklist check score (max 2 pts)
- example: 2.0
- content_score:
- type: number
- format: float
- minimum: 0
- maximum: 2
- description: Content quality score (max 2 pts)
- example: 1.8
+ maximum: 100
+ description: Blacklist check score (in percentage)
+ example: 20
+ blacklist_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
header_score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Header quality score (in percentage)
+ example: 9
+ header_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
+ content_score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Content quality score (in percentage)
+ example: 18
+ content_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score (A+ is best, F is worst)
+ example: "A"
+
+ ContentAnalysis:
+ type: object
+ properties:
+ has_html:
+ type: boolean
+ description: Whether email contains HTML part
+ example: true
+ has_plaintext:
+ type: boolean
+ description: Whether email contains plaintext part
+ example: true
+ html_issues:
+ type: array
+ items:
+ $ref: '#/components/schemas/ContentIssue'
+ description: Issues found in HTML content
+ links:
+ type: array
+ items:
+ $ref: '#/components/schemas/LinkCheck'
+ description: Analysis of links found in the email
+ images:
+ type: array
+ items:
+ $ref: '#/components/schemas/ImageCheck'
+ description: Analysis of images in the email
+ text_to_image_ratio:
type: number
format: float
- minimum: 0
- maximum: 1
- description: Header quality score (max 1 pt)
- example: 0.9
+ description: Ratio of text to images (higher is better)
+ example: 0.75
+ has_unsubscribe_link:
+ type: boolean
+ description: Whether email contains an unsubscribe link
+ example: true
+ unsubscribe_methods:
+ type: array
+ items:
+ type: string
+ enum: [link, mailto, list-unsubscribe-header, one-click]
+ description: Available unsubscribe methods
+ example: ["link", "list-unsubscribe-header"]
- Check:
+ ContentIssue:
type: object
required:
- - category
- - name
- - status
- - score
+ - type
+ - severity
- message
properties:
- category:
+ type:
type: string
- enum: [authentication, dns, content, blacklist, headers, spam]
- description: Check category
- example: "authentication"
- name:
- type: string
- description: Check name
- example: "DKIM Signature"
- status:
- type: string
- enum: [pass, fail, warn, info, error]
- description: Check result status
- example: "pass"
- score:
- type: number
- format: float
- description: Points contributed to total score
- example: 1.0
- message:
- type: string
- description: Human-readable result message
- example: "DKIM signature is valid"
- details:
- type: string
- description: Additional details (may be JSON)
+ enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html]
+ description: Type of content issue
+ example: "missing_alt"
severity:
type: string
enum: [critical, high, medium, low, info]
description: Issue severity
- example: "info"
+ example: "medium"
+ message:
+ type: string
+ description: Human-readable description
+ example: "3 images are missing alt attributes"
+ location:
+ type: string
+ description: Where the issue was found
+ example: "HTML body line 42"
advice:
type: string
- description: Remediation advice
- example: "Your DKIM configuration is correct"
+ description: How to fix this issue
+ example: "Add descriptive alt text to all images for better accessibility and deliverability"
+
+ LinkCheck:
+ type: object
+ required:
+ - url
+ - status
+ properties:
+ url:
+ type: string
+ format: uri
+ description: The URL found in the email
+ example: "https://example.com/page"
+ status:
+ type: string
+ enum: [valid, broken, suspicious, redirected, timeout]
+ description: Link validation status
+ example: "valid"
+ http_code:
+ type: integer
+ description: HTTP status code received
+ example: 200
+ redirect_chain:
+ type: array
+ items:
+ type: string
+ description: URLs in the redirect chain, if any
+ example: ["https://example.com", "https://www.example.com"]
+ is_shortened:
+ type: boolean
+ description: Whether this is a URL shortener
+ example: false
+
+ ImageCheck:
+ type: object
+ required:
+ - has_alt
+ properties:
+ src:
+ type: string
+ description: Image source URL or path
+ example: "https://example.com/logo.png"
+ has_alt:
+ type: boolean
+ description: Whether image has alt attribute
+ example: true
+ alt_text:
+ type: string
+ description: Alt text content
+ example: "Company Logo"
+ is_tracking_pixel:
+ type: boolean
+ description: Whether this appears to be a tracking pixel (1x1 image)
+ example: false
+
+ HeaderAnalysis:
+ type: object
+ properties:
+ has_mime_structure:
+ type: boolean
+ description: Whether body has a MIME structure
+ example: true
+ headers:
+ type: object
+ additionalProperties:
+ $ref: '#/components/schemas/HeaderCheck'
+ description: Map of header names to their check results (e.g., "from", "to", "dkim-signature")
+ example:
+ from:
+ present: true
+ value: "sender@example.com"
+ valid: true
+ importance: "required"
+ date:
+ present: true
+ value: "Mon, 1 Jan 2024 12:00:00 +0000"
+ valid: true
+ importance: "required"
+ received_chain:
+ type: array
+ items:
+ $ref: '#/components/schemas/ReceivedHop'
+ description: Chain of Received headers showing email path
+ domain_alignment:
+ $ref: '#/components/schemas/DomainAlignment'
+ issues:
+ type: array
+ items:
+ $ref: '#/components/schemas/HeaderIssue'
+ description: Issues found in headers
+
+ HeaderCheck:
+ type: object
+ required:
+ - present
+ properties:
+ present:
+ type: boolean
+ description: Whether the header is present
+ example: true
+ value:
+ type: string
+ description: Header value
+ example: "sender@example.com"
+ valid:
+ type: boolean
+ description: Whether the value is valid/well-formed
+ example: true
+ importance:
+ type: string
+ enum: [required, recommended, optional, newsletter]
+ description: How important this header is for deliverability
+ example: "required"
+ issues:
+ type: array
+ items:
+ type: string
+ description: Any issues with this header
+ example: ["Invalid date format"]
+
+ ReceivedHop:
+ type: object
+ properties:
+ from:
+ type: string
+ description: Sending server hostname
+ example: "mail.example.com"
+ by:
+ type: string
+ description: Receiving server hostname
+ example: "mx.receiver.com"
+ with:
+ type: string
+ description: Protocol used
+ example: "ESMTPS"
+ id:
+ type: string
+ description: Message ID at this hop
+ timestamp:
+ type: string
+ format: date-time
+ description: When this hop occurred
+ ip:
+ type: string
+ description: IP address of the sending server (IPv4 or IPv6)
+ example: "192.0.2.1"
+ reverse:
+ type: string
+ description: Reverse DNS (PTR record) for the IP address
+ example: "mail.example.com"
+
+ DKIMDomainInfo:
+ type: object
+ required:
+ - domain
+ - org_domain
+ properties:
+ domain:
+ type: string
+ description: DKIM signature domain
+ example: "mail.example.com"
+ org_domain:
+ type: string
+ description: Organizational domain extracted from DKIM domain (using Public Suffix List)
+ example: "example.com"
+
+ DomainAlignment:
+ type: object
+ properties:
+ from_domain:
+ type: string
+ description: Domain from From header
+ example: "example.com"
+ from_org_domain:
+ type: string
+ description: Organizational domain extracted from From header (using Public Suffix List)
+ example: "example.com"
+ return_path_domain:
+ type: string
+ description: Domain from Return-Path header
+ example: "example.com"
+ return_path_org_domain:
+ type: string
+ description: Organizational domain extracted from Return-Path header (using Public Suffix List)
+ example: "example.com"
+ dkim_domains:
+ type: array
+ items:
+ $ref: '#/components/schemas/DKIMDomainInfo'
+ description: Domains from DKIM signatures with their organizational domains
+ aligned:
+ type: boolean
+ description: Whether all domains align (strict alignment - exact match)
+ example: true
+ relaxed_aligned:
+ type: boolean
+ description: Whether domains satisfy relaxed alignment (organizational domain match)
+ example: true
+ issues:
+ type: array
+ items:
+ type: string
+ description: Alignment issues
+ example: ["Return-Path domain does not match From domain"]
+
+ HeaderIssue:
+ type: object
+ required:
+ - header
+ - severity
+ - message
+ properties:
+ header:
+ type: string
+ description: Header name
+ example: "Date"
+ severity:
+ type: string
+ enum: [critical, high, medium, low, info]
+ description: Issue severity
+ example: "medium"
+ message:
+ type: string
+ description: Human-readable description
+ example: "Date header is in the future"
+ advice:
+ type: string
+ description: How to fix this issue
+ example: "Ensure your mail server clock is synchronized with NTP"
AuthenticationResults:
type: object
@@ -353,6 +769,18 @@ components:
$ref: '#/components/schemas/AuthResult'
dmarc:
$ref: '#/components/schemas/AuthResult'
+ bimi:
+ $ref: '#/components/schemas/AuthResult'
+ arc:
+ $ref: '#/components/schemas/ARCResult'
+ iprev:
+ $ref: '#/components/schemas/IPRevResult'
+ x_google_dkim:
+ $ref: '#/components/schemas/AuthResult'
+ description: Google-specific DKIM authentication result (x-google-dkim)
+ x_aligned_from:
+ $ref: '#/components/schemas/AuthResult'
+ description: X-Aligned-From authentication result (checks address alignment)
AuthResult:
type: object
@@ -361,7 +789,7 @@ components:
properties:
result:
type: string
- enum: [pass, fail, none, neutral, softfail, temperror, permerror]
+ enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
description: Authentication result
example: "pass"
domain:
@@ -376,13 +804,75 @@ components:
type: string
description: Additional details about the result
+ ARCResult:
+ type: object
+ required:
+ - result
+ properties:
+ result:
+ type: string
+ enum: [pass, fail, none]
+ description: Overall ARC chain validation result
+ example: "pass"
+ chain_valid:
+ type: boolean
+ description: Whether the ARC chain signatures are valid
+ example: true
+ chain_length:
+ type: integer
+ description: Number of ARC sets in the chain
+ example: 2
+ details:
+ type: string
+ description: Additional details about ARC validation
+ example: "ARC chain valid with 2 intermediaries"
+
+ IPRevResult:
+ type: object
+ required:
+ - result
+ properties:
+ result:
+ type: string
+ enum: [pass, fail, temperror, permerror]
+ description: IP reverse DNS lookup result
+ example: "pass"
+ ip:
+ type: string
+ description: IP address that was checked
+ example: "195.110.101.58"
+ hostname:
+ type: string
+ description: Hostname from reverse DNS lookup (PTR record)
+ example: "authsmtp74.register.it"
+ details:
+ type: string
+ description: Additional details about the IP reverse lookup
+ example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)"
+
SpamAssassinResult:
type: object
required:
- score
- required_score
- is_spam
+ - test_details
properties:
+ deliverability_score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: SpamAssassin deliverability score (0-100, higher is better)
+ example: 80
+ deliverability_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade for SpamAssassin deliverability score
+ example: "B"
+ version:
+ type: string
+ description: SpamAssassin version
+ example: "SpamAssassin 4.0.1"
score:
type: number
format: float
@@ -403,47 +893,342 @@ components:
type: string
description: List of triggered SpamAssassin tests
example: ["BAYES_00", "DKIM_SIGNED"]
+ test_details:
+ type: object
+ additionalProperties:
+ $ref: '#/components/schemas/SpamTestDetail'
+ description: Map of test names to their detailed results
+ example:
+ BAYES_00:
+ name: "BAYES_00"
+ score: -1.9
+ description: "Bayes spam probability is 0 to 1%"
+ DKIM_SIGNED:
+ name: "DKIM_SIGNED"
+ score: 0.1
+ description: "Message has a DKIM or DK signature, not necessarily valid"
report:
type: string
description: Full SpamAssassin report
- DNSRecord:
+ SpamTestDetail:
type: object
required:
- - domain
- - record_type
- - status
+ - name
+ - score
properties:
+ name:
+ type: string
+ description: Test name
+ example: "BAYES_00"
+ score:
+ type: number
+ format: float
+ description: Score contribution of this test
+ example: -1.9
+ description:
+ type: string
+ description: Human-readable description of what this test checks
+ example: "Bayes spam probability is 0 to 1%"
+
+ RspamdResult:
+ type: object
+ required:
+ - score
+ - threshold
+ - is_spam
+ - symbols
+ properties:
+ deliverability_score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: rspamd deliverability score (0-100, higher is better)
+ example: 85
+ deliverability_grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade for rspamd deliverability score
+ example: "A"
+ score:
+ type: number
+ format: float
+ description: rspamd spam score
+ example: -3.91
+ threshold:
+ type: number
+ format: float
+ description: Score threshold for spam classification
+ example: 15.0
+ action:
+ type: string
+ description: rspamd action (no action, add header, rewrite subject, soft reject, reject)
+ example: "no action"
+ is_spam:
+ type: boolean
+ description: Whether message is classified as spam (action is reject or soft reject)
+ example: false
+ server:
+ type: string
+ description: rspamd server that processed the message
+ example: "rspamd.example.com"
+ symbols:
+ type: object
+ additionalProperties:
+ $ref: '#/components/schemas/RspamdSymbol'
+ description: Map of triggered rspamd symbols to their details
+ example:
+ BAYES_HAM:
+ name: "BAYES_HAM"
+ score: -1.9
+ params: "0.02"
+ report:
+ type: string
+ description: Full rspamd report (raw X-Spamd-Result header)
+
+ RspamdSymbol:
+ type: object
+ required:
+ - name
+ - score
+ properties:
+ name:
+ type: string
+ description: Symbol name
+ example: "BAYES_HAM"
+ score:
+ type: number
+ format: float
+ description: Score contribution of this symbol
+ example: -1.9
+ params:
+ type: string
+ description: Symbol parameters or options
+ example: "0.02"
+
+ DNSResults:
+ type: object
+ required:
+ - from_domain
+ properties:
+ from_domain:
+ type: string
+ description: From Domain name
+ example: "example.com"
+ rp_domain:
+ type: string
+ description: Return Path Domain name
+ example: "example.com"
+ from_mx_records:
+ type: array
+ items:
+ $ref: '#/components/schemas/MXRecord'
+ description: MX records for the From domain
+ rp_mx_records:
+ type: array
+ items:
+ $ref: '#/components/schemas/MXRecord'
+ description: MX records for the Return-Path domain
+ spf_records:
+ type: array
+ items:
+ $ref: '#/components/schemas/SPFRecord'
+ description: SPF records found (includes resolved include directives)
+ dkim_records:
+ type: array
+ items:
+ $ref: '#/components/schemas/DKIMRecord'
+ description: DKIM records found
+ dmarc_record:
+ $ref: '#/components/schemas/DMARCRecord'
+ bimi_record:
+ $ref: '#/components/schemas/BIMIRecord'
+ ptr_records:
+ type: array
+ items:
+ type: string
+ description: PTR (reverse DNS) records for the sender IP address
+ example: ["mail.example.com", "smtp.example.com"]
+ ptr_forward_records:
+ type: array
+ items:
+ type: string
+ description: A or AAAA records resolved from the PTR hostnames (forward confirmation)
+ example: ["192.0.2.1", "2001:db8::1"]
+ errors:
+ type: array
+ items:
+ type: string
+ description: DNS lookup errors
+
+ MXRecord:
+ type: object
+ required:
+ - host
+ - priority
+ - valid
+ properties:
+ host:
+ type: string
+ description: MX hostname
+ example: "mail.example.com"
+ priority:
+ type: integer
+ format: uint16
+ description: MX priority (lower is higher priority)
+ example: 10
+ valid:
+ type: boolean
+ description: Whether the MX record is valid
+ example: true
+ error:
+ type: string
+ description: Error message if validation failed
+ example: "Failed to lookup MX records"
+
+ SPFRecord:
+ type: object
+ required:
+ - valid
+ properties:
+ domain:
+ type: string
+ description: Domain this SPF record belongs to
+ example: "example.com"
+ record:
+ type: string
+ description: SPF record content
+ example: "v=spf1 include:_spf.example.com ~all"
+ valid:
+ type: boolean
+ description: Whether the SPF record is valid
+ example: true
+ all_qualifier:
+ type: string
+ enum: ["+", "-", "~", "?"]
+ description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)"
+ example: "~"
+ error:
+ type: string
+ description: Error message if validation failed
+ example: "No SPF record found"
+
+ DKIMRecord:
+ type: object
+ required:
+ - selector
+ - domain
+ - valid
+ properties:
+ selector:
+ type: string
+ description: DKIM selector
+ example: "default"
domain:
type: string
description: Domain name
example: "example.com"
- record_type:
+ record:
type: string
- enum: [MX, SPF, DKIM, DMARC]
- description: DNS record type
- example: "SPF"
- status:
+ description: DKIM record content
+ example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."
+ valid:
+ type: boolean
+ description: Whether the DKIM record is valid
+ example: true
+ error:
type: string
- enum: [found, missing, invalid]
- description: Record status
- example: "found"
- value:
+ description: Error message if validation failed
+ example: "No DKIM record found"
+
+ DMARCRecord:
+ type: object
+ required:
+ - valid
+ properties:
+ record:
type: string
- description: Record value
- example: "v=spf1 include:_spf.example.com ~all"
+ description: DMARC record content
+ example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
+ policy:
+ type: string
+ enum: [none, quarantine, reject, unknown]
+ description: DMARC policy
+ example: "quarantine"
+ subdomain_policy:
+ type: string
+ enum: [none, quarantine, reject, unknown]
+ description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy
+ example: "quarantine"
+ percentage:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Percentage of messages subjected to filtering (pct tag, default 100)
+ example: 100
+ spf_alignment:
+ type: string
+ enum: [relaxed, strict]
+ description: SPF alignment mode (aspf tag)
+ example: "relaxed"
+ dkim_alignment:
+ type: string
+ enum: [relaxed, strict]
+ description: DKIM alignment mode (adkim tag)
+ example: "relaxed"
+ valid:
+ type: boolean
+ description: Whether the DMARC record is valid
+ example: true
+ error:
+ type: string
+ description: Error message if validation failed
+ example: "No DMARC record found"
+
+ BIMIRecord:
+ type: object
+ required:
+ - selector
+ - domain
+ - valid
+ properties:
+ selector:
+ type: string
+ description: BIMI selector
+ example: "default"
+ domain:
+ type: string
+ description: Domain name
+ example: "example.com"
+ record:
+ type: string
+ description: BIMI record content
+ example: "v=BIMI1; l=https://example.com/logo.svg"
+ logo_url:
+ type: string
+ format: uri
+ description: URL to the brand logo (SVG)
+ example: "https://example.com/logo.svg"
+ vmc_url:
+ type: string
+ format: uri
+ description: URL to Verified Mark Certificate (optional)
+ example: "https://example.com/vmc.pem"
+ valid:
+ type: boolean
+ description: Whether the BIMI record is valid
+ example: true
+ error:
+ type: string
+ description: Error message if validation failed
+ example: "No BIMI record found"
BlacklistCheck:
type: object
required:
- - ip
- rbl
- listed
properties:
- ip:
- type: string
- description: IP address checked
- example: "192.0.2.1"
rbl:
type: string
description: RBL/DNSBL name
@@ -456,6 +1241,9 @@ components:
type: string
description: RBL response code or message
example: "127.0.0.2"
+ error:
+ type: string
+ description: RBL error if any
Status:
type: object
@@ -505,3 +1293,90 @@ components:
details:
type: string
description: Additional error details
+
+ DomainTestRequest:
+ type: object
+ required:
+ - domain
+ properties:
+ domain:
+ type: string
+ pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
+ description: Domain name to test (e.g., example.com)
+ example: "example.com"
+
+ DomainTestResponse:
+ type: object
+ required:
+ - domain
+ - score
+ - grade
+ - dns_results
+ properties:
+ domain:
+ type: string
+ description: The tested domain name
+ example: "example.com"
+ score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Overall domain configuration score (0-100)
+ example: 85
+ grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score
+ example: "A"
+ dns_results:
+ $ref: '#/components/schemas/DNSResults'
+
+ BlacklistCheckRequest:
+ type: object
+ required:
+ - ip
+ properties:
+ ip:
+ type: string
+ description: IPv4 or IPv6 address to check against blacklists
+ example: "192.0.2.1"
+ pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$'
+
+ BlacklistCheckResponse:
+ type: object
+ required:
+ - ip
+ - blacklists
+ - listed_count
+ - score
+ - grade
+ properties:
+ ip:
+ type: string
+ description: The IP address that was checked
+ example: "192.0.2.1"
+ blacklists:
+ type: array
+ items:
+ $ref: '#/components/schemas/BlacklistCheck'
+ description: List of blacklist check results
+ listed_count:
+ type: integer
+ description: Number of blacklists that have this IP listed
+ example: 0
+ score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Blacklist score (0-100, higher is better)
+ example: 100
+ grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade representation of the score
+ example: "A+"
+ whitelists:
+ type: array
+ items:
+ $ref: '#/components/schemas/BlacklistCheck'
+ description: List of DNS whitelist check results (informational only)
diff --git a/banner.webp b/banner.webp
new file mode 100644
index 0000000..8ed7da1
Binary files /dev/null and b/banner.webp differ
diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go
index 3dc3fae..3caf4d1 100644
--- a/cmd/happyDeliver/main.go
+++ b/cmd/happyDeliver/main.go
@@ -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()
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ccfd313
--- /dev/null
+++ b/docker-compose.yml
@@ -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:
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..3769365
--- /dev/null
+++ b/docker/README.md
@@ -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
diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json
new file mode 100644
index 0000000..5db3bbc
--- /dev/null
+++ b/docker/authentication_milter/authentication_milter.json
@@ -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" : {}
+ }
+}
diff --git a/docker/authentication_milter/mail-dmarc.ini b/docker/authentication_milter/mail-dmarc.ini
new file mode 100644
index 0000000..8097ac6
--- /dev/null
+++ b/docker/authentication_milter/mail-dmarc.ini
@@ -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 =
\ No newline at end of file
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..ef45b61
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -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 <> /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 "$@"
diff --git a/docker/postfix/aliases b/docker/postfix/aliases
new file mode 100644
index 0000000..e910b5d
--- /dev/null
+++ b/docker/postfix/aliases
@@ -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
diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf
new file mode 100644
index 0000000..5a73fb3
--- /dev/null
+++ b/docker/postfix/main.cf
@@ -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
diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf
new file mode 100644
index 0000000..9c2ac57
--- /dev/null
+++ b/docker/postfix/master.cf
@@ -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}
diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps
new file mode 100644
index 0000000..cc1deed
--- /dev/null
+++ b/docker/postfix/transport_maps
@@ -0,0 +1,4 @@
+# Transport map - route test emails to happyDeliver LMTP server
+# Pattern: test-@domain.com -> LMTP on localhost:2525
+
+/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525
diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf
new file mode 100644
index 0000000..f3ed60c
--- /dev/null
+++ b/docker/rspamd/local.d/actions.conf
@@ -0,0 +1,5 @@
+no_action = 0;
+reject = null;
+add_header = null;
+rewrite_subject = null;
+greylist = null;
\ No newline at end of file
diff --git a/docker/rspamd/local.d/milter_headers.conf b/docker/rspamd/local.d/milter_headers.conf
new file mode 100644
index 0000000..378b8a3
--- /dev/null
+++ b/docker/rspamd/local.d/milter_headers.conf
@@ -0,0 +1,5 @@
+# Add "extended Rspamd headers"
+extended_spam_headers = true;
+
+skip_local = false;
+skip_authenticated = false;
\ No newline at end of file
diff --git a/docker/rspamd/local.d/options.inc b/docker/rspamd/local.d/options.inc
new file mode 100644
index 0000000..485d0c9
--- /dev/null
+++ b/docker/rspamd/local.d/options.inc
@@ -0,0 +1,3 @@
+# rspamd options for happyDeliver
+# Disable Bayes learning to keep the setup stateless
+use_redis = false;
diff --git a/docker/rspamd/local.d/worker-proxy.inc b/docker/rspamd/local.d/worker-proxy.inc
new file mode 100644
index 0000000..04c9a1d
--- /dev/null
+++ b/docker/rspamd/local.d/worker-proxy.inc
@@ -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;
+}
diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf
new file mode 100644
index 0000000..ce9a31c
--- /dev/null
+++ b/docker/spamassassin/local.cf
@@ -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
\ No newline at end of file
diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf
new file mode 100644
index 0000000..74f1810
--- /dev/null
+++ b/docker/supervisor/supervisord.conf
@@ -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"
diff --git a/go.mod b/go.mod
index 8a2e2d9..d44d5cc 100644
--- a/go.mod
+++ b/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
)
diff --git a/go.sum b/go.sum
index 033b798..717c4ff 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go
deleted file mode 100644
index 45df0a3..0000000
--- a/internal/analyzer/authentication.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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
-}
diff --git a/internal/analyzer/content.go b/internal/analyzer/content.go
deleted file mode 100644
index bad38c9..0000000
--- a/internal/analyzer/content.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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
-}
diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go
deleted file mode 100644
index 07c0346..0000000
--- a/internal/analyzer/dns.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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
-}
diff --git a/internal/analyzer/dns_test.go b/internal/analyzer/dns_test.go
deleted file mode 100644
index fe501d5..0000000
--- a/internal/analyzer/dns_test.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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")
- }
-}
diff --git a/internal/analyzer/rbl.go b/internal/analyzer/rbl.go
deleted file mode 100644
index be7366c..0000000
--- a/internal/analyzer/rbl.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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
-}
diff --git a/internal/analyzer/report.go b/internal/analyzer/report.go
deleted file mode 100644
index fe30c6c..0000000
--- a/internal/analyzer/report.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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)
-}
diff --git a/internal/analyzer/report_test.go b/internal/analyzer/report_test.go
deleted file mode 100644
index 4a8fe00..0000000
--- a/internal/analyzer/report_test.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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{""}
-
- return &EmailMessage{
- Header: header,
- From: &mail.Address{Address: "sender@example.com"},
- To: []*mail.Address{{Address: "recipient@example.com"}},
- Subject: "Test Email",
- MessageID: "",
- 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: \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: "Test
Link",
- IsHTML: true,
- })
-
- return email
-}
diff --git a/internal/analyzer/scoring.go b/internal/analyzer/scoring.go
deleted file mode 100644
index 07f6a34..0000000
--- a/internal/analyzer/scoring.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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: ")
- 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()
-}
diff --git a/internal/analyzer/scoring_test.go b/internal/analyzer/scoring_test.go
deleted file mode 100644
index b28182d..0000000
--- a/internal/analyzer/scoring_test.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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: "",
- 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: "",
- expected: false,
- },
- {
- name: "Multiple @ symbols",
- messageID: "",
- expected: false,
- },
- {
- name: "Empty local part",
- messageID: "<@example.com>",
- expected: false,
- },
- {
- name: "Empty domain part",
- messageID: "",
- 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": "",
- "Reply-To": "reply@example.com",
- }),
- MessageID: "",
- 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": "",
- }),
- MessageID: "",
- 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": "",
- "Reply-To": "reply@example.com",
- }),
- MessageID: "",
- 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": "",
- }),
- MessageID: "",
- 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": "",
- }),
- From: &mail.Address{Address: "sender@example.com"},
- MessageID: "",
- 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: "",
- 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: "test
"},
- },
- 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": "",
- "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
-}
diff --git a/internal/analyzer/spamassassin.go b/internal/analyzer/spamassassin.go
deleted file mode 100644
index 78a6a72..0000000
--- a/internal/analyzer/spamassassin.go
+++ /dev/null
@@ -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 .
-//
-// 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 .
-
-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
-}
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
new file mode 100644
index 0000000..470136e
--- /dev/null
+++ b/internal/api/handlers.go
@@ -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 .
+//
+// 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 .
+
+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)
+}
diff --git a/internal/api/helpers.go b/internal/api/helpers.go
index b50def0..cce306a 100644
--- a/internal/api/helpers.go
+++ b/internal/api/helpers.go
@@ -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
diff --git a/internal/app/cleanup.go b/internal/app/cleanup.go
new file mode 100644
index 0000000..c640df9
--- /dev/null
+++ b/internal/app/cleanup.go
@@ -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 .
+//
+// 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 .
+
+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")
+ }
+}
diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go
new file mode 100644
index 0000000..d8336a5
--- /dev/null
+++ b/internal/app/cli_analyzer.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go
new file mode 100644
index 0000000..4b01fbb
--- /dev/null
+++ b/internal/app/cli_backup.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/app/server.go b/internal/app/server.go
new file mode 100644
index 0000000..7149f45
--- /dev/null
+++ b/internal/app/server.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/config/cli.go b/internal/config/cli.go
new file mode 100644
index 0000000..3accc99
--- /dev/null
+++ b/internal/config/cli.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..468a2aa
--- /dev/null
+++ b/internal/config/config.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/config/custom.go b/internal/config/custom.go
new file mode 100644
index 0000000..97c8d71
--- /dev/null
+++ b/internal/config/custom.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/config/env.go b/internal/config/env.go
new file mode 100644
index 0000000..cd4c344
--- /dev/null
+++ b/internal/config/env.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/config/file.go b/internal/config/file.go
new file mode 100644
index 0000000..ec28a58
--- /dev/null
+++ b/internal/config/file.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go
new file mode 100644
index 0000000..a9b36b9
--- /dev/null
+++ b/internal/lmtp/server.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go
new file mode 100644
index 0000000..062a091
--- /dev/null
+++ b/internal/receiver/receiver.go
@@ -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 .
+//
+// 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 .
+
+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-@domain.com
+func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
+ // Remove angle brackets if present (e.g., )
+ 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")
+}
diff --git a/internal/storage/models.go b/internal/storage/models.go
new file mode 100644
index 0000000..dbb3daa
--- /dev/null
+++ b/internal/storage/models.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
new file mode 100644
index 0000000..39b2eb6
--- /dev/null
+++ b/internal/storage/storage.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go
new file mode 100644
index 0000000..ebbbbdf
--- /dev/null
+++ b/internal/utils/uuid.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..a46c79f
--- /dev/null
+++ b/internal/version/version.go
@@ -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 .
+//
+// 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 .
+
+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)"
diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go
new file mode 100644
index 0000000..a16829b
--- /dev/null
+++ b/pkg/analyzer/analyzer.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go
new file mode 100644
index 0000000..07f7794
--- /dev/null
+++ b/pkg/analyzer/authentication.go
@@ -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 .
+//
+// 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 .
+
+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)
+}
diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go
new file mode 100644
index 0000000..01b7505
--- /dev/null
+++ b/pkg/analyzer/authentication_arc.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go
new file mode 100644
index 0000000..9269d70
--- /dev/null
+++ b/pkg/analyzer/authentication_arc_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go
new file mode 100644
index 0000000..0d68281
--- /dev/null
+++ b/pkg/analyzer/authentication_bimi.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go
new file mode 100644
index 0000000..b1b5468
--- /dev/null
+++ b/pkg/analyzer/authentication_bimi_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go
new file mode 100644
index 0000000..b6cf5f8
--- /dev/null
+++ b/pkg/analyzer/authentication_dkim.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go
new file mode 100644
index 0000000..2aab530
--- /dev/null
+++ b/pkg/analyzer/authentication_dkim_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go
new file mode 100644
index 0000000..329a5c9
--- /dev/null
+++ b/pkg/analyzer/authentication_dmarc.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go
new file mode 100644
index 0000000..d7fda84
--- /dev/null
+++ b/pkg/analyzer/authentication_dmarc_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go
new file mode 100644
index 0000000..6538cbb
--- /dev/null
+++ b/pkg/analyzer/authentication_iprev.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go
new file mode 100644
index 0000000..d0529b5
--- /dev/null
+++ b/pkg/analyzer/authentication_iprev_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go
new file mode 100644
index 0000000..479c325
--- /dev/null
+++ b/pkg/analyzer/authentication_spf.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go
new file mode 100644
index 0000000..7a84c49
--- /dev/null
+++ b/pkg/analyzer/authentication_spf_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go
new file mode 100644
index 0000000..27901b5
--- /dev/null
+++ b/pkg/analyzer/authentication_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+}
diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go
new file mode 100644
index 0000000..36da2b0
--- /dev/null
+++ b/pkg/analyzer/authentication_x_aligned_from.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go
new file mode 100644
index 0000000..220ac39
--- /dev/null
+++ b/pkg/analyzer/authentication_x_aligned_from_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go
new file mode 100644
index 0000000..4bba469
--- /dev/null
+++ b/pkg/analyzer/authentication_x_google_dkim.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go
new file mode 100644
index 0000000..be29a08
--- /dev/null
+++ b/pkg/analyzer/authentication_x_google_dkim_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go
new file mode 100644
index 0000000..d14d157
--- /dev/null
+++ b/pkg/analyzer/content.go
@@ -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 .
+//
+// 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 .
+
+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 More
",
- expectedText: "TextMore",
+ expectedText: "Text More",
},
{
name: "With style tag",
html: "Text
More
",
- 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: "HTML text
",
- 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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go
new file mode 100644
index 0000000..3098934
--- /dev/null
+++ b/pkg/analyzer/dns.go
@@ -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 .
+//
+// 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 .
+
+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)
+}
diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go
new file mode 100644
index 0000000..44240e9
--- /dev/null
+++ b/pkg/analyzer/dns_bimi.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/dns_bimi_test.go b/pkg/analyzer/dns_bimi_test.go
new file mode 100644
index 0000000..cf7df83
--- /dev/null
+++ b/pkg/analyzer/dns_bimi_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go
new file mode 100644
index 0000000..7ac858d
--- /dev/null
+++ b/pkg/analyzer/dns_dkim.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go
new file mode 100644
index 0000000..8d94d20
--- /dev/null
+++ b/pkg/analyzer/dns_dkim_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go
new file mode 100644
index 0000000..3b73ecc
--- /dev/null
+++ b/pkg/analyzer/dns_dmarc.go
@@ -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 .
+//
+// 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 .
+
+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=
+ 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
+}
diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go
new file mode 100644
index 0000000..0868e48
--- /dev/null
+++ b/pkg/analyzer/dns_dmarc_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go
new file mode 100644
index 0000000..f90e5dc
--- /dev/null
+++ b/pkg/analyzer/dns_fcr.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go
new file mode 100644
index 0000000..68e55b5
--- /dev/null
+++ b/pkg/analyzer/dns_mx.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go
new file mode 100644
index 0000000..f60484f
--- /dev/null
+++ b/pkg/analyzer/dns_resolver.go
@@ -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 .
+//
+// 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 .
+
+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)
+}
diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go
new file mode 100644
index 0000000..bfa1640
--- /dev/null
+++ b/pkg/analyzer/dns_spf.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go
new file mode 100644
index 0000000..2e794ce
--- /dev/null
+++ b/pkg/analyzer/dns_spf_test.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go
new file mode 100644
index 0000000..bba4503
--- /dev/null
+++ b/pkg/analyzer/dns_test.go
@@ -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 .
+//
+// 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 .
+
+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")
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go
new file mode 100644
index 0000000..37718bb
--- /dev/null
+++ b/pkg/analyzer/headers.go
@@ -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 .
+//
+// 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 .
+
+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 " 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 " 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 )")
+ }
+ 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: "),
+ })
+ }
+
+ 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 " 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
+}
diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go
new file mode 100644
index 0000000..2513e6f
--- /dev/null
+++ b/pkg/analyzer/headers_test.go
@@ -0,0 +1,1079 @@
+// 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 .
+//
+// 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 .
+
+package analyzer
+
+import (
+ "net/mail"
+ "net/textproto"
+ "strings"
+ "testing"
+
+ "git.happydns.org/happyDeliver/internal/api"
+)
+
+func TestCalculateHeaderScore(t *testing.T) {
+ tests := []struct {
+ name string
+ email *EmailMessage
+ minScore int
+ maxScore int
+ }{
+ {
+ name: "Nil email",
+ email: nil,
+ minScore: 0,
+ maxScore: 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": "",
+ "Reply-To": "reply@example.com",
+ }),
+ MessageID: "",
+ Date: "Mon, 01 Jan 2024 12:00:00 +0000",
+ Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
+ },
+ minScore: 70,
+ maxScore: 100,
+ },
+ {
+ name: "Missing required headers",
+ email: &EmailMessage{
+ Header: createHeaderWithFields(map[string]string{
+ "Subject": "Test",
+ }),
+ },
+ minScore: 0,
+ maxScore: 40,
+ },
+ {
+ 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": "",
+ }),
+ MessageID: "",
+ Date: "Mon, 01 Jan 2024 12:00:00 +0000",
+ Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
+ },
+ minScore: 80,
+ maxScore: 90,
+ },
+ {
+ 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: 70,
+ maxScore: 100,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Generate header analysis first
+ analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil)
+ score, _ := analyzer.CalculateHeaderScore(analysis)
+ if score < tt.minScore || score > tt.maxScore {
+ t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
+ }
+ })
+ }
+}
+
+func TestCheckHeader(t *testing.T) {
+ tests := []struct {
+ name string
+ headerName string
+ headerValue string
+ importance string
+ expectedPresent bool
+ expectedValid bool
+ expectedIssuesLen int
+ }{
+ {
+ name: "Valid Message-ID",
+ headerName: "Message-ID",
+ headerValue: "",
+ importance: "required",
+ expectedPresent: true,
+ expectedValid: true,
+ expectedIssuesLen: 0,
+ },
+ {
+ name: "Invalid Message-ID format",
+ headerName: "Message-ID",
+ headerValue: "invalid-message-id",
+ importance: "required",
+ expectedPresent: true,
+ expectedValid: false,
+ expectedIssuesLen: 1,
+ },
+ {
+ name: "Missing required header",
+ headerName: "From",
+ headerValue: "",
+ importance: "required",
+ expectedPresent: false,
+ expectedValid: false,
+ expectedIssuesLen: 1,
+ },
+ {
+ name: "Missing optional header",
+ headerName: "Reply-To",
+ headerValue: "",
+ importance: "optional",
+ expectedPresent: false,
+ expectedValid: false,
+ expectedIssuesLen: 0,
+ },
+ {
+ name: "Valid Date header",
+ headerName: "Date",
+ headerValue: "Mon, 01 Jan 2024 12:00:00 +0000",
+ importance: "required",
+ expectedPresent: true,
+ expectedValid: true,
+ expectedIssuesLen: 0,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ email := &EmailMessage{
+ Header: createHeaderWithFields(map[string]string{
+ tt.headerName: tt.headerValue,
+ }),
+ }
+
+ check := analyzer.checkHeader(email, tt.headerName, tt.importance)
+
+ if check.Present != tt.expectedPresent {
+ t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent)
+ }
+
+ if check.Valid != nil && *check.Valid != tt.expectedValid {
+ t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid)
+ }
+
+ if check.Importance == nil {
+ t.Error("Importance is nil")
+ } else if string(*check.Importance) != tt.importance {
+ t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance)
+ }
+
+ issuesLen := 0
+ if check.Issues != nil {
+ issuesLen = len(*check.Issues)
+ }
+ if issuesLen != tt.expectedIssuesLen {
+ t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen)
+ }
+ })
+ }
+}
+
+func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) {
+ tests := []struct {
+ name string
+ messageID string
+ expected bool
+ }{
+ {
+ name: "Valid Message-ID",
+ messageID: "",
+ expected: true,
+ },
+ {
+ name: "Valid with complex local part",
+ messageID: "",
+ expected: true,
+ },
+ {
+ name: "Missing angle brackets",
+ messageID: "abc123@example.com",
+ expected: false,
+ },
+ {
+ name: "Missing @ symbol",
+ messageID: "",
+ expected: false,
+ },
+ {
+ name: "Empty local part",
+ messageID: "<@example.com>",
+ expected: false,
+ },
+ {
+ name: "Empty domain",
+ messageID: "",
+ expected: false,
+ },
+ {
+ name: "Multiple @ symbols",
+ messageID: "",
+ expected: false,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.isValidMessageID(tt.messageID)
+ if result != tt.expected {
+ t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestHeaderAnalyzer_ExtractDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ email string
+ expected string
+ }{
+ {
+ name: "Simple email",
+ email: "user@example.com",
+ expected: "example.com",
+ },
+ {
+ name: "Email with angle brackets",
+ email: "",
+ expected: "example.com",
+ },
+ {
+ name: "Email with display name",
+ email: "User Name ",
+ expected: "example.com",
+ },
+ {
+ name: "Email with spaces",
+ email: " user@example.com ",
+ expected: "example.com",
+ },
+ {
+ name: "Invalid email",
+ email: "not-an-email",
+ expected: "",
+ },
+ {
+ name: "Empty string",
+ email: "",
+ expected: "",
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.extractDomain(tt.email)
+ if result != tt.expected {
+ t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestAnalyzeDomainAlignment(t *testing.T) {
+ tests := []struct {
+ name string
+ fromHeader string
+ returnPath string
+ expectAligned bool
+ expectIssuesLen int
+ }{
+ {
+ name: "Aligned domains",
+ fromHeader: "sender@example.com",
+ returnPath: "bounce@example.com",
+ expectAligned: true,
+ expectIssuesLen: 0,
+ },
+ {
+ name: "Misaligned domains",
+ fromHeader: "sender@example.com",
+ returnPath: "bounce@different.com",
+ expectAligned: false,
+ expectIssuesLen: 1,
+ },
+ {
+ name: "Only From header",
+ fromHeader: "sender@example.com",
+ returnPath: "",
+ expectAligned: true,
+ expectIssuesLen: 0,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ email := &EmailMessage{
+ Header: createHeaderWithFields(map[string]string{
+ "From": tt.fromHeader,
+ "Return-Path": tt.returnPath,
+ }),
+ }
+
+ alignment := analyzer.analyzeDomainAlignment(email, nil)
+
+ if alignment == nil {
+ t.Fatal("Expected non-nil alignment")
+ }
+
+ if alignment.Aligned == nil {
+ t.Fatal("Expected non-nil Aligned field")
+ }
+
+ if *alignment.Aligned != tt.expectAligned {
+ t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned)
+ }
+
+ issuesLen := 0
+ if alignment.Issues != nil {
+ issuesLen = len(*alignment.Issues)
+ }
+ if issuesLen != tt.expectIssuesLen {
+ t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen)
+ }
+ })
+ }
+}
+
+// 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
+}
+
+func TestParseReceivedChain(t *testing.T) {
+ tests := []struct {
+ name string
+ receivedHeaders []string
+ expectedHops int
+ validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
+ }{
+ {
+ name: "No Received headers",
+ receivedHeaders: []string{},
+ expectedHops: 0,
+ },
+ {
+ name: "Single Received header",
+ receivedHeaders: []string{
+ "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000",
+ },
+ expectedHops: 1,
+ validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
+ if len(hops) == 0 {
+ t.Fatal("Expected at least one hop")
+ }
+ hop := hops[0]
+
+ if hop.From == nil || *hop.From != "mail.example.com" {
+ t.Errorf("From = %v, want 'mail.example.com'", hop.From)
+ }
+ if hop.By == nil || *hop.By != "mx.receiver.com" {
+ t.Errorf("By = %v, want 'mx.receiver.com'", hop.By)
+ }
+ if hop.With == nil || *hop.With != "ESMTPS" {
+ t.Errorf("With = %v, want 'ESMTPS'", hop.With)
+ }
+ if hop.Id == nil || *hop.Id != "ABC123" {
+ t.Errorf("Id = %v, want 'ABC123'", hop.Id)
+ }
+ if hop.Ip == nil || *hop.Ip != "192.0.2.1" {
+ t.Errorf("Ip = %v, want '192.0.2.1'", hop.Ip)
+ }
+ if hop.Timestamp == nil {
+ t.Error("Timestamp should not be nil")
+ }
+ },
+ },
+ {
+ name: "Multiple Received headers",
+ receivedHeaders: []string{
+ "from mail1.example.com (mail1.example.com [192.0.2.1]) by mx1.receiver.com with ESMTP id 111; Mon, 01 Jan 2024 12:00:00 +0000",
+ "from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
+ },
+ expectedHops: 2,
+ validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
+ if len(hops) != 2 {
+ t.Fatalf("Expected 2 hops, got %d", len(hops))
+ }
+
+ // Check first hop
+ if hops[0].From == nil || *hops[0].From != "mail1.example.com" {
+ t.Errorf("First hop From = %v, want 'mail1.example.com'", hops[0].From)
+ }
+
+ // Check second hop
+ if hops[1].From == nil || *hops[1].From != "mail2.example.com" {
+ t.Errorf("Second hop From = %v, want 'mail2.example.com'", hops[1].From)
+ }
+ },
+ },
+ {
+ name: "IPv6 address",
+ receivedHeaders: []string{
+ "from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
+ },
+ expectedHops: 1,
+ validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
+ if len(hops) == 0 {
+ t.Fatal("Expected at least one hop")
+ }
+ hop := hops[0]
+
+ if hop.Ip == nil {
+ t.Fatal("IP should not be nil for IPv6 address")
+ }
+ // Should strip the "IPv6:" prefix
+ if *hop.Ip != "2607:5300:203:2818::1" {
+ t.Errorf("Ip = %v, want '2607:5300:203:2818::1'", *hop.Ip)
+ }
+ },
+ },
+ {
+ name: "Multiline Received header",
+ receivedHeaders: []string{
+ `from nemunai.re (unknown [IPv6:2607:5300:203:2818::1])
+ (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
+ key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256)
+ (No client certificate requested)
+ (Authenticated sender: nemunaire)
+ by djehouty.pomail.fr (Postfix) with ESMTPSA id 1EFD11611EA
+ for ; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
+ },
+ expectedHops: 1,
+ validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
+ if len(hops) == 0 {
+ t.Fatal("Expected at least one hop")
+ }
+ hop := hops[0]
+
+ if hop.From == nil || *hop.From != "nemunai.re" {
+ t.Errorf("From = %v, want 'nemunai.re'", hop.From)
+ }
+ if hop.By == nil || *hop.By != "djehouty.pomail.fr" {
+ t.Errorf("By = %v, want 'djehouty.pomail.fr'", hop.By)
+ }
+ if hop.With == nil {
+ t.Error("With should not be nil")
+ } else if *hop.With != "ESMTPSA" {
+ t.Errorf("With = %q, want 'ESMTPSA'", *hop.With)
+ }
+ if hop.Id == nil || *hop.Id != "1EFD11611EA" {
+ t.Errorf("Id = %v, want '1EFD11611EA'", hop.Id)
+ }
+ },
+ },
+ {
+ name: "Received header with minimal information",
+ receivedHeaders: []string{
+ "from unknown by localhost",
+ },
+ expectedHops: 1,
+ validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
+ if len(hops) == 0 {
+ t.Fatal("Expected at least one hop")
+ }
+ hop := hops[0]
+
+ if hop.From == nil || *hop.From != "unknown" {
+ t.Errorf("From = %v, want 'unknown'", hop.From)
+ }
+ if hop.By == nil || *hop.By != "localhost" {
+ t.Errorf("By = %v, want 'localhost'", hop.By)
+ }
+ },
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ header := make(mail.Header)
+ if len(tt.receivedHeaders) > 0 {
+ header["Received"] = tt.receivedHeaders
+ }
+
+ email := &EmailMessage{
+ Header: header,
+ }
+
+ chain := analyzer.parseReceivedChain(email)
+
+ if len(chain) != tt.expectedHops {
+ t.Errorf("parseReceivedChain() returned %d hops, want %d", len(chain), tt.expectedHops)
+ }
+
+ if tt.validateFirst != nil {
+ tt.validateFirst(t, email, chain)
+ }
+ })
+ }
+}
+
+func TestParseReceivedHeader(t *testing.T) {
+ tests := []struct {
+ name string
+ receivedValue string
+ expectFrom *string
+ expectBy *string
+ expectWith *string
+ expectId *string
+ expectIp *string
+ expectHasTs bool
+ }{
+ {
+ name: "Complete Received header",
+ receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000",
+ expectFrom: strPtr("mail.example.com"),
+ expectBy: strPtr("mx.receiver.com"),
+ expectWith: strPtr("ESMTPS"),
+ expectId: strPtr("ABC123"),
+ expectIp: strPtr("192.0.2.1"),
+ expectHasTs: true,
+ },
+ {
+ name: "Minimal Received header",
+ receivedValue: "from sender.example.com by receiver.example.com",
+ expectFrom: strPtr("sender.example.com"),
+ expectBy: strPtr("receiver.example.com"),
+ expectWith: nil,
+ expectId: nil,
+ expectIp: nil,
+ expectHasTs: false,
+ },
+ {
+ name: "Received header with ESMTPA",
+ receivedValue: "from [192.0.2.50] by mail.example.com with ESMTPA id XYZ789; Tue, 02 Jan 2024 08:30:00 -0500",
+ expectFrom: strPtr("[192.0.2.50]"),
+ expectBy: strPtr("mail.example.com"),
+ expectWith: strPtr("ESMTPA"),
+ expectId: strPtr("XYZ789"),
+ expectIp: strPtr("192.0.2.50"),
+ expectHasTs: true,
+ },
+ {
+ name: "Received header without IP",
+ receivedValue: "from mail.example.com by mx.receiver.com with SMTP; Wed, 03 Jan 2024 14:20:00 +0000",
+ expectFrom: strPtr("mail.example.com"),
+ expectBy: strPtr("mx.receiver.com"),
+ expectWith: strPtr("SMTP"),
+ expectId: nil,
+ expectIp: nil,
+ expectHasTs: true,
+ },
+ {
+ name: "Postfix local delivery with userid",
+ receivedValue: "by grunt.ycc.fr (Postfix, from userid 1000) id 67276801A8; Fri, 24 Oct 2025 04:17:25 +0200 (CEST)",
+ expectFrom: nil,
+ expectBy: strPtr("grunt.ycc.fr"),
+ expectWith: nil,
+ expectId: strPtr("67276801A8"),
+ expectIp: nil,
+ expectHasTs: true,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hop := analyzer.parseReceivedHeader(tt.receivedValue)
+
+ if hop == nil {
+ t.Fatal("parseReceivedHeader returned nil")
+ }
+
+ // Check From
+ if !equalStrPtr(hop.From, tt.expectFrom) {
+ t.Errorf("From = %v, want %v", ptrToStr(hop.From), ptrToStr(tt.expectFrom))
+ }
+
+ // Check By
+ if !equalStrPtr(hop.By, tt.expectBy) {
+ t.Errorf("By = %v, want %v", ptrToStr(hop.By), ptrToStr(tt.expectBy))
+ }
+
+ // Check With
+ if !equalStrPtr(hop.With, tt.expectWith) {
+ t.Errorf("With = %v, want %v", ptrToStr(hop.With), ptrToStr(tt.expectWith))
+ }
+
+ // Check Id
+ if !equalStrPtr(hop.Id, tt.expectId) {
+ t.Errorf("Id = %v, want %v", ptrToStr(hop.Id), ptrToStr(tt.expectId))
+ }
+
+ // Check Ip
+ if !equalStrPtr(hop.Ip, tt.expectIp) {
+ t.Errorf("Ip = %v, want %v", ptrToStr(hop.Ip), ptrToStr(tt.expectIp))
+ }
+
+ // Check Timestamp
+ if tt.expectHasTs {
+ if hop.Timestamp == nil {
+ t.Error("Timestamp should not be nil")
+ }
+ }
+ })
+ }
+}
+
+func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
+ analyzer := NewHeaderAnalyzer()
+
+ 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": "",
+ }),
+ MessageID: "",
+ Date: "Mon, 01 Jan 2024 12:00:00 +0000",
+ Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
+ }
+
+ // Add Received headers
+ email.Header["Received"] = []string{
+ "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com with ESMTP id ABC123; Mon, 01 Jan 2024 12:00:00 +0000",
+ "from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000",
+ }
+
+ analysis := analyzer.GenerateHeaderAnalysis(email, nil)
+
+ if analysis == nil {
+ t.Fatal("GenerateHeaderAnalysis returned nil")
+ }
+
+ if analysis.ReceivedChain == nil {
+ t.Fatal("ReceivedChain should not be nil")
+ }
+
+ chain := *analysis.ReceivedChain
+ if len(chain) != 2 {
+ t.Fatalf("Expected 2 hops in ReceivedChain, got %d", len(chain))
+ }
+
+ // Check first hop
+ if chain[0].From == nil || *chain[0].From != "mail.example.com" {
+ t.Errorf("First hop From = %v, want 'mail.example.com'", chain[0].From)
+ }
+
+ // Check second hop
+ if chain[1].From == nil || *chain[1].From != "relay.example.com" {
+ t.Errorf("Second hop From = %v, want 'relay.example.com'", chain[1].From)
+ }
+}
+
+func TestHeaderAnalyzer_ParseEmailDate(t *testing.T) {
+ tests := []struct {
+ name string
+ dateStr string
+ expectError bool
+ expectYear int
+ expectMonth int
+ expectDay int
+ }{
+ {
+ name: "RFC1123Z format",
+ dateStr: "Mon, 02 Jan 2006 15:04:05 -0700",
+ expectError: false,
+ expectYear: 2006,
+ expectMonth: 1,
+ expectDay: 2,
+ },
+ {
+ name: "RFC1123 format",
+ dateStr: "Mon, 02 Jan 2006 15:04:05 MST",
+ expectError: false,
+ expectYear: 2006,
+ expectMonth: 1,
+ expectDay: 2,
+ },
+ {
+ name: "Single digit day",
+ dateStr: "Mon, 2 Jan 2006 15:04:05 -0700",
+ expectError: false,
+ expectYear: 2006,
+ expectMonth: 1,
+ expectDay: 2,
+ },
+ {
+ name: "Without day of week",
+ dateStr: "2 Jan 2006 15:04:05 -0700",
+ expectError: false,
+ expectYear: 2006,
+ expectMonth: 1,
+ expectDay: 2,
+ },
+ {
+ name: "With timezone name in parentheses",
+ dateStr: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)",
+ expectError: false,
+ expectYear: 2024,
+ expectMonth: 1,
+ expectDay: 1,
+ },
+ {
+ name: "With timezone name in parentheses 2",
+ dateStr: "Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
+ expectError: false,
+ expectYear: 2025,
+ expectMonth: 10,
+ expectDay: 19,
+ },
+ {
+ name: "With CEST timezone",
+ dateStr: "Fri, 24 Oct 2025 04:17:25 +0200 (CEST)",
+ expectError: false,
+ expectYear: 2025,
+ expectMonth: 10,
+ expectDay: 24,
+ },
+ {
+ name: "Invalid date format",
+ dateStr: "not a date",
+ expectError: true,
+ },
+ {
+ name: "Empty string",
+ dateStr: "",
+ expectError: true,
+ },
+ {
+ name: "ISO 8601 format (should fail)",
+ dateStr: "2024-01-01T12:00:00Z",
+ expectError: true,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := analyzer.parseEmailDate(tt.dateStr)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("parseEmailDate(%q) expected error, got nil", tt.dateStr)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("parseEmailDate(%q) unexpected error: %v", tt.dateStr, err)
+ return
+ }
+
+ if result.Year() != tt.expectYear {
+ t.Errorf("Year = %d, want %d", result.Year(), tt.expectYear)
+ }
+ if int(result.Month()) != tt.expectMonth {
+ t.Errorf("Month = %d, want %d", result.Month(), tt.expectMonth)
+ }
+ if result.Day() != tt.expectDay {
+ t.Errorf("Day = %d, want %d", result.Day(), tt.expectDay)
+ }
+ }
+ })
+ }
+}
+
+func TestCheckHeader_DateValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ dateValue string
+ expectedValid bool
+ expectedIssuesLen int
+ }{
+ {
+ name: "Valid RFC1123Z date",
+ dateValue: "Mon, 02 Jan 2006 15:04:05 -0700",
+ expectedValid: true,
+ expectedIssuesLen: 0,
+ },
+ {
+ name: "Valid date with timezone name",
+ dateValue: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)",
+ expectedValid: true,
+ expectedIssuesLen: 0,
+ },
+ {
+ name: "Invalid date format",
+ dateValue: "2024-01-01",
+ expectedValid: false,
+ expectedIssuesLen: 1,
+ },
+ {
+ name: "Invalid date string",
+ dateValue: "not a date",
+ expectedValid: false,
+ expectedIssuesLen: 1,
+ },
+ {
+ name: "Empty date",
+ dateValue: "",
+ expectedValid: false,
+ expectedIssuesLen: 1,
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ email := &EmailMessage{
+ Header: createHeaderWithFields(map[string]string{
+ "Date": tt.dateValue,
+ }),
+ }
+
+ check := analyzer.checkHeader(email, "Date", "required")
+
+ if check.Valid != nil && *check.Valid != tt.expectedValid {
+ t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid)
+ }
+
+ issuesLen := 0
+ if check.Issues != nil {
+ issuesLen = len(*check.Issues)
+ }
+ if issuesLen != tt.expectedIssuesLen {
+ t.Errorf("Issues length = %d, want %d (issues: %v)", issuesLen, tt.expectedIssuesLen, check.Issues)
+ }
+ })
+ }
+}
+
+// Helper functions for testing
+func strPtr(s string) *string {
+ return &s
+}
+
+func ptrToStr(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+}
+
+func equalStrPtr(a, b *string) bool {
+ if a == nil && b == nil {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return *a == *b
+}
+
+func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
+ tests := []struct {
+ name string
+ fromHeader string
+ returnPath string
+ dkimDomains []string
+ expectStrictAligned bool
+ expectRelaxedAligned bool
+ expectIssuesContain string
+ }{
+ {
+ name: "DKIM strict alignment with From domain",
+ fromHeader: "sender@example.com",
+ returnPath: "",
+ dkimDomains: []string{"example.com"},
+ expectStrictAligned: true,
+ expectRelaxedAligned: true,
+ expectIssuesContain: "",
+ },
+ {
+ name: "DKIM relaxed alignment only",
+ fromHeader: "sender@mail.example.com",
+ returnPath: "",
+ dkimDomains: []string{"example.com"},
+ expectStrictAligned: false,
+ expectRelaxedAligned: true,
+ expectIssuesContain: "relaxed alignment",
+ },
+ {
+ name: "DKIM no alignment",
+ fromHeader: "sender@example.com",
+ returnPath: "",
+ dkimDomains: []string{"different.com"},
+ expectStrictAligned: false,
+ expectRelaxedAligned: false,
+ expectIssuesContain: "do not align",
+ },
+ {
+ name: "Multiple DKIM signatures - one aligns",
+ fromHeader: "sender@example.com",
+ returnPath: "",
+ dkimDomains: []string{"different.com", "example.com"},
+ expectStrictAligned: true,
+ expectRelaxedAligned: true,
+ expectIssuesContain: "",
+ },
+ {
+ name: "Return-Path misaligned but DKIM aligned",
+ fromHeader: "sender@example.com",
+ returnPath: "bounce@different.com",
+ dkimDomains: []string{"example.com"},
+ expectStrictAligned: true,
+ expectRelaxedAligned: true,
+ expectIssuesContain: "Return-Path",
+ },
+ {
+ name: "Return-Path aligned, no DKIM",
+ fromHeader: "sender@example.com",
+ returnPath: "bounce@example.com",
+ dkimDomains: []string{},
+ expectStrictAligned: true,
+ expectRelaxedAligned: true,
+ expectIssuesContain: "",
+ },
+ {
+ name: "Both Return-Path and DKIM misaligned",
+ fromHeader: "sender@example.com",
+ returnPath: "bounce@other.com",
+ dkimDomains: []string{"different.com"},
+ expectStrictAligned: false,
+ expectRelaxedAligned: false,
+ expectIssuesContain: "do not",
+ },
+ }
+
+ analyzer := NewHeaderAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ email := &EmailMessage{
+ Header: createHeaderWithFields(map[string]string{
+ "From": tt.fromHeader,
+ "Return-Path": tt.returnPath,
+ }),
+ }
+
+ // Create authentication results with DKIM signatures
+ var authResults *api.AuthenticationResults
+ if len(tt.dkimDomains) > 0 {
+ dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
+ for _, domain := range tt.dkimDomains {
+ dkimResults = append(dkimResults, api.AuthResult{
+ Result: api.AuthResultResultPass,
+ Domain: &domain,
+ })
+ }
+ authResults = &api.AuthenticationResults{
+ Dkim: &dkimResults,
+ }
+ }
+
+ alignment := analyzer.analyzeDomainAlignment(email, authResults)
+
+ if alignment == nil {
+ t.Fatal("Expected non-nil alignment")
+ }
+
+ if alignment.Aligned == nil {
+ t.Fatal("Expected non-nil Aligned field")
+ }
+
+ if *alignment.Aligned != tt.expectStrictAligned {
+ t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned)
+ }
+
+ if alignment.RelaxedAligned == nil {
+ t.Fatal("Expected non-nil RelaxedAligned field")
+ }
+
+ if *alignment.RelaxedAligned != tt.expectRelaxedAligned {
+ t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned)
+ }
+
+ // Check DKIM domains are populated
+ if len(tt.dkimDomains) > 0 {
+ if alignment.DkimDomains == nil {
+ t.Error("Expected DkimDomains to be populated")
+ } else if len(*alignment.DkimDomains) != len(tt.dkimDomains) {
+ t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains))
+ }
+ }
+
+ // Check issues contain expected string
+ if tt.expectIssuesContain != "" {
+ if alignment.Issues == nil || len(*alignment.Issues) == 0 {
+ t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain)
+ } else {
+ found := false
+ for _, issue := range *alignment.Issues {
+ if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/internal/analyzer/parser.go b/pkg/analyzer/parser.go
similarity index 80%
rename from internal/analyzer/parser.go
rename to pkg/analyzer/parser.go
index 13c012c..5b30e07 100644
--- a/internal/analyzer/parser.go
+++ b/pkg/analyzer/parser.go
@@ -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: , , ...
+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
+}
diff --git a/internal/analyzer/parser_test.go b/pkg/analyzer/parser_test.go
similarity index 99%
rename from internal/analyzer/parser_test.go
rename to pkg/analyzer/parser_test.go
index 571f542..eb1fc6a 100644
--- a/internal/analyzer/parser_test.go
+++ b/pkg/analyzer/parser_test.go
@@ -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
diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go
new file mode 100644
index 0000000..08d3b8f
--- /dev/null
+++ b/pkg/analyzer/rbl.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/internal/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go
similarity index 57%
rename from internal/analyzer/rbl_test.go
rename to pkg/analyzer/rbl_test.go
index a75ef19..1dd1262 100644
--- a/internal/analyzer/rbl_test.go
+++ b/pkg/analyzer/rbl_test.go
@@ -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))
diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go
new file mode 100644
index 0000000..bd12960
--- /dev/null
+++ b/pkg/analyzer/report.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go
new file mode 100644
index 0000000..82e923e
--- /dev/null
+++ b/pkg/analyzer/report_test.go
@@ -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 .
+//
+// 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 .
+
+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{""}
+
+ return &EmailMessage{
+ Header: header,
+ From: &mail.Address{Address: "sender@example.com"},
+ To: []*mail.Address{{Address: "recipient@example.com"}},
+ Subject: "Test Email",
+ MessageID: "",
+ 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: \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
+}
diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go
new file mode 100644
index 0000000..f3f548b
--- /dev/null
+++ b/pkg/analyzer/rspamd.go
@@ -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 .
+//
+// 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 .
+
+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)
+}
diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go
new file mode 100644
index 0000000..de98fe8
--- /dev/null
+++ b/pkg/analyzer/rspamd_test.go
@@ -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 .
+//
+// 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 .
+
+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:
+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)
+ }
+}
+
diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go
new file mode 100644
index 0000000..798590f
--- /dev/null
+++ b/pkg/analyzer/scoring.go
@@ -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 .
+//
+// 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 .
+
+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
+}
diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go
new file mode 100644
index 0000000..7964af2
--- /dev/null
+++ b/pkg/analyzer/spamassassin.go
@@ -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 .
+//
+// 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 .
+
+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)
+ }
+}
diff --git a/internal/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go
similarity index 51%
rename from internal/analyzer/spamassassin_test.go
rename to pkg/analyzer/spamassassin_test.go
index 4682ed3..b539f24 100644
--- a/internal/analyzer/spamassassin_test.go
+++ b/pkg/analyzer/spamassassin_test.go
@@ -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:
+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)
}
}
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..958a423
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "local>iac/renovate-config",
+ "local>iac/renovate-config//automerge-common"
+ ]
+}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..d7033d5
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,26 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+# OpenAPI
+src/lib/api
\ No newline at end of file
diff --git a/web/.npmrc b/web/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/web/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/web/.prettierignore b/web/.prettierignore
new file mode 100644
index 0000000..7d74fe2
--- /dev/null
+++ b/web/.prettierignore
@@ -0,0 +1,9 @@
+# Package Managers
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
+bun.lock
+bun.lockb
+
+# Miscellaneous
+/static/
diff --git a/web/.prettierrc b/web/.prettierrc
new file mode 100644
index 0000000..f9333ff
--- /dev/null
+++ b/web/.prettierrc
@@ -0,0 +1,13 @@
+{
+ "tabWidth": 4,
+ "printWidth": 100,
+ "plugins": ["prettier-plugin-svelte"],
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": {
+ "parser": "svelte"
+ }
+ }
+ ]
+}
diff --git a/web/assets.go b/web/assets.go
new file mode 100644
index 0000000..9b6ace7
--- /dev/null
+++ b/web/assets.go
@@ -0,0 +1,43 @@
+// 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 .
+//
+// 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 .
+
+package web
+
+import (
+ "embed"
+ "io/fs"
+ "log"
+ "net/http"
+)
+
+//go:embed all:build
+
+var _assets embed.FS
+
+var Assets http.FileSystem
+
+func init() {
+ sub, err := fs.Sub(_assets, "build")
+ if err != nil {
+ log.Fatal("Unable to cd to build/ directory:", err)
+ }
+ Assets = http.FS(sub)
+}
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000..a477855
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,41 @@
+import prettier from "eslint-config-prettier";
+import { fileURLToPath } from "node:url";
+import { includeIgnoreFile } from "@eslint/compat";
+import js from "@eslint/js";
+import svelte from "eslint-plugin-svelte";
+import { defineConfig } from "eslint/config";
+import globals from "globals";
+import ts from "typescript-eslint";
+import svelteConfig from "./svelte.config.js";
+
+const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
+
+export default defineConfig(
+ includeIgnoreFile(gitignorePath),
+ js.configs.recommended,
+ ...ts.configs.recommended,
+ ...svelte.configs.recommended,
+ prettier,
+ ...svelte.configs.prettier,
+ {
+ languageOptions: {
+ globals: { ...globals.browser, ...globals.node },
+ },
+ rules: {
+ // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
+ // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
+ "no-undef": "off",
+ },
+ },
+ {
+ files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ extraFileExtensions: [".svelte"],
+ parser: ts.parser,
+ svelteConfig,
+ },
+ },
+ },
+);
diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts
new file mode 100644
index 0000000..dfe34de
--- /dev/null
+++ b/web/openapi-ts.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "@hey-api/openapi-ts";
+
+export default defineConfig({
+ input: "../api/openapi.yaml",
+ output: "src/lib/api",
+ plugins: [
+ {
+ name: "@hey-api/client-fetch",
+ runtimeConfigPath: "$lib/hey-api.ts",
+ },
+ ],
+});
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..835218b
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,4384 @@
+{
+ "name": "happyDeliver",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "happyDeliver",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-or-later",
+ "dependencies": {
+ "bootstrap": "^5.3.8",
+ "bootstrap-icons": "^1.13.1"
+ },
+ "devDependencies": {
+ "@eslint/compat": "^2.0.0",
+ "@eslint/js": "^9.36.0",
+ "@hey-api/openapi-ts": "0.86.10",
+ "@sveltejs/adapter-static": "^3.0.9",
+ "@sveltejs/kit": "^2.43.2",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
+ "@types/node": "^24.0.0",
+ "eslint": "^9.38.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.12.4",
+ "globals": "^17.0.0",
+ "prettier": "^3.6.2",
+ "prettier-plugin-svelte": "^3.4.0",
+ "svelte": "^5.39.5",
+ "svelte-check": "^4.3.2",
+ "typescript": "^5.9.2",
+ "typescript-eslint": "^8.44.1",
+ "vite": "^7.1.10",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/compat": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz",
+ "integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.1.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "peerDependencies": {
+ "eslint": "^8.40 || 9 || 10"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
+ "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
+ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
+ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@hey-api/codegen-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.3.tgz",
+ "integrity": "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/hey-api"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.5.3"
+ }
+ },
+ "node_modules/@hey-api/json-schema-ref-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz",
+ "integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.15",
+ "js-yaml": "^4.1.0",
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/hey-api"
+ }
+ },
+ "node_modules/@hey-api/openapi-ts": {
+ "version": "0.86.10",
+ "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.10.tgz",
+ "integrity": "sha512-Ns0dTJp/RUrOMPiJsO4/1E2Sa3VZ1iw2KCdG6PDbd9vLwOXEYW2UmiWMDPOTInLCYB+f8FLMF9T25jtfQe7AZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@hey-api/codegen-core": "^0.3.2",
+ "@hey-api/json-schema-ref-parser": "1.2.1",
+ "ansi-colors": "4.1.3",
+ "c12": "3.3.1",
+ "color-support": "1.1.3",
+ "commander": "14.0.1",
+ "handlebars": "4.7.8",
+ "open": "10.2.0",
+ "semver": "7.7.2"
+ },
+ "bin": {
+ "openapi-ts": "bin/run.js"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/hey-api"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.5.3"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@jsdevtools/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+ "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+ "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+ "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+ "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+ "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+ "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+ "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+ "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+ "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+ "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+ "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+ "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+ "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+ "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+ "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+ "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+ "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+ "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+ "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+ "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+ "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+ "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-static": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
+ "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.50.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz",
+ "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
+ "cookie": "^0.6.0",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "sade": "^1.8.1",
+ "set-cookie-parser": "^2.6.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.9",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
+ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
+ "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.54.0",
+ "@typescript-eslint/type-utils": "8.54.0",
+ "@typescript-eslint/utils": "8.54.0",
+ "@typescript-eslint/visitor-keys": "8.54.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.54.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
+ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.54.0",
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/typescript-estree": "8.54.0",
+ "@typescript-eslint/visitor-keys": "8.54.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz",
+ "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.54.0",
+ "@typescript-eslint/types": "^8.54.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz",
+ "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/visitor-keys": "8.54.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz",
+ "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz",
+ "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/typescript-estree": "8.54.0",
+ "@typescript-eslint/utils": "8.54.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz",
+ "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz",
+ "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.54.0",
+ "@typescript-eslint/tsconfig-utils": "8.54.0",
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/visitor-keys": "8.54.0",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz",
+ "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.54.0",
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/typescript-estree": "8.54.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz",
+ "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.54.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/bootstrap": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
+ "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@popperjs/core": "^2.11.8"
+ }
+ },
+ "node_modules/bootstrap-icons": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
+ "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/c12": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.1.tgz",
+ "integrity": "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^17.2.3",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.6.1",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^2.0.0",
+ "pkg-types": "^2.3.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/commander": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz",
+ "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/confbox": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
+ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/default-browser": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
+ "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
+ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/devalue": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
+ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dotenv": {
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
+ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.39.2",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-svelte": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.14.0.tgz",
+ "integrity": "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.6.1",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "esutils": "^2.0.3",
+ "globals": "^16.0.0",
+ "known-css-properties": "^0.37.0",
+ "postcss": "^8.4.49",
+ "postcss-load-config": "^3.1.4",
+ "postcss-safe-parser": "^7.0.0",
+ "semver": "^7.6.3",
+ "svelte-eslint-parser": "^1.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ota-meshi"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.1 || ^9.0.0",
+ "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "svelte": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-svelte/node_modules/globals": {
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrap": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
+ "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.3.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
+ "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/handlebars": {
+ "version": "4.7.8",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+ "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
+ "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/known-css-properties": {
+ "version": "0.37.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
+ "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
+ "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nypm": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
+ "integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.2.0",
+ "pathe": "^2.0.3",
+ "tinyexec": "^1.0.2"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz",
+ "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/open": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
+ "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.2.1",
+ "define-lazy-prop": "^3.0.0",
+ "is-inside-container": "^1.0.0",
+ "wsl-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/perfect-debounce": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
+ "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.2",
+ "exsolve": "^1.0.7",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
+ "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^2.0.5",
+ "yaml": "^1.10.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": ">=8.0.9",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-load-config/node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-safe-parser": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
+ "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.31"
+ }
+ },
+ "node_modules/postcss-scss": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz",
+ "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss-scss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.29"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-plugin-svelte": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz",
+ "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "prettier": "^3.0.0",
+ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.57.1",
+ "@rollup/rollup-android-arm64": "4.57.1",
+ "@rollup/rollup-darwin-arm64": "4.57.1",
+ "@rollup/rollup-darwin-x64": "4.57.1",
+ "@rollup/rollup-freebsd-arm64": "4.57.1",
+ "@rollup/rollup-freebsd-x64": "4.57.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+ "@rollup/rollup-linux-arm64-musl": "4.57.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+ "@rollup/rollup-linux-loong64-musl": "4.57.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-musl": "4.57.1",
+ "@rollup/rollup-openbsd-x64": "4.57.1",
+ "@rollup/rollup-openharmony-arm64": "4.57.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+ "@rollup/rollup-win32-x64-gnu": "4.57.1",
+ "@rollup/rollup-win32-x64-msvc": "4.57.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.49.1",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
+ "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.2",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.6.tgz",
+ "integrity": "sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/svelte-eslint-parser": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz",
+ "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-scope": "^8.2.0",
+ "eslint-visitor-keys": "^4.0.0",
+ "espree": "^10.0.0",
+ "postcss": "^8.4.49",
+ "postcss-scss": "^4.0.9",
+ "postcss-selector-parser": "^7.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0",
+ "pnpm": "10.24.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ota-meshi"
+ },
+ "peerDependencies": {
+ "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "svelte": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz",
+ "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.54.0",
+ "@typescript-eslint/parser": "8.54.0",
+ "@typescript-eslint/typescript-estree": "8.54.0",
+ "@typescript-eslint/utils": "8.54.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wsl-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
+ "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..e5b88f2
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "happyDeliver",
+ "version": "0.1.0",
+ "type": "module",
+ "license": "AGPL-3.0-or-later",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "test": "vitest",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "format": "prettier --write .",
+ "lint": "prettier --check . && eslint .",
+ "generate:api": "openapi-ts"
+ },
+ "devDependencies": {
+ "@eslint/compat": "^2.0.0",
+ "@eslint/js": "^9.36.0",
+ "@hey-api/openapi-ts": "0.86.10",
+ "@sveltejs/adapter-static": "^3.0.9",
+ "@sveltejs/kit": "^2.43.2",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
+ "@types/node": "^24.0.0",
+ "eslint": "^9.38.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.12.4",
+ "globals": "^17.0.0",
+ "prettier": "^3.6.2",
+ "prettier-plugin-svelte": "^3.4.0",
+ "svelte": "^5.39.5",
+ "svelte-check": "^4.3.2",
+ "typescript": "^5.9.2",
+ "typescript-eslint": "^8.44.1",
+ "vite": "^7.1.10",
+ "vitest": "^3.2.4"
+ },
+ "dependencies": {
+ "bootstrap": "^5.3.8",
+ "bootstrap-icons": "^1.13.1"
+ }
+}
diff --git a/web/routes.go b/web/routes.go
new file mode 100644
index 0000000..876954c
--- /dev/null
+++ b/web/routes.go
@@ -0,0 +1,204 @@
+// 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 .
+//
+// 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 .
+
+package web
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "text/template"
+
+ "github.com/gin-gonic/gin"
+
+ "git.happydns.org/happyDeliver/internal/config"
+)
+
+var (
+ indexTpl *template.Template
+ CustomBodyHTML = ""
+ CustomHeadHTML = ""
+)
+
+func init() {
+ flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before ")
+ flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before
+
+ %sveltekit.body%
+ ")
+}
+
+func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
+ appConfig := map[string]interface{}{}
+
+ if cfg.ReportRetention > 0 {
+ appConfig["report_retention"] = cfg.ReportRetention
+ }
+
+ if cfg.SurveyURL.Host != "" {
+ appConfig["survey_url"] = cfg.SurveyURL.String()
+ }
+
+ if len(cfg.Analysis.RBLs) > 0 {
+ appConfig["rbls"] = cfg.Analysis.RBLs
+ }
+
+ if cfg.CustomLogoURL != "" {
+ appConfig["custom_logo_url"] = cfg.CustomLogoURL
+ }
+
+ if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
+ log.Println("Unable to generate JSON config to inject in web application")
+ } else {
+ CustomHeadHTML += ``
+ }
+
+ if cfg.DevProxy != "" {
+ router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
+ router.GET("/node_modules/*_", serveOrReverse("", cfg))
+ router.GET("/@vite/*_", serveOrReverse("", cfg))
+ router.GET("/@id/*_", serveOrReverse("", cfg))
+ router.GET("/@fs/*_", serveOrReverse("", cfg))
+ router.GET("/src/*_", serveOrReverse("", cfg))
+ router.GET("/home/*_", serveOrReverse("", cfg))
+ }
+ router.GET("/_app/", serveOrReverse("", cfg))
+ router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
+
+ router.GET("/", serveOrReverse("/", cfg))
+ router.GET("/blacklist/", serveOrReverse("/", cfg))
+ router.GET("/blacklist/:ip", serveOrReverse("/", cfg))
+ router.GET("/domain/", serveOrReverse("/", cfg))
+ router.GET("/domain/:domain", serveOrReverse("/", cfg))
+ router.GET("/test/", serveOrReverse("/", cfg))
+ router.GET("/test/:testid", serveOrReverse("/", cfg))
+ router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
+ router.GET("/img/*path", serveOrReverse("", cfg))
+
+ router.NoRoute(func(c *gin.Context) {
+ if strings.HasPrefix(c.Request.URL.Path, "/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") {
+ c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "errmsg": "Page not found"})
+ } else {
+ serveOrReverse("/", cfg)(c)
+ }
+ })
+}
+
+func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
+ if cfg.DevProxy != "" {
+ // Forward to the Svelte dev proxy
+ return func(c *gin.Context) {
+ if u, err := url.Parse(cfg.DevProxy); err != nil {
+ http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
+ } else {
+ if forced_url != "" && forced_url != "/" {
+ u.Path = path.Join(u.Path, forced_url)
+ } else {
+ u.Path = path.Join(u.Path, c.Request.URL.Path)
+ }
+
+ u.RawQuery = c.Request.URL.RawQuery
+
+ if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
+ http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
+ } else if resp, err := http.DefaultClient.Do(r); err != nil {
+ http.Error(c.Writer, err.Error(), http.StatusBadGateway)
+ } else {
+ defer resp.Body.Close()
+
+ if u.Path != "/" || resp.StatusCode != 200 {
+ for key := range resp.Header {
+ c.Writer.Header().Add(key, resp.Header.Get(key))
+ }
+ c.Writer.WriteHeader(resp.StatusCode)
+
+ io.Copy(c.Writer, resp.Body)
+ } else {
+ for key := range resp.Header {
+ if strings.ToLower(key) != "content-length" {
+ c.Writer.Header().Add(key, resp.Header.Get(key))
+ }
+ }
+
+ v, _ := io.ReadAll(resp.Body)
+
+ v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1)
+
+ indexTpl = template.Must(template.New("index.html").Parse(v2))
+
+ if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
+ "Body": CustomBodyHTML,
+ "Head": CustomHeadHTML,
+ "RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
+ }); err != nil {
+ log.Println("Unable to return index.html:", err.Error())
+ }
+ }
+ }
+ }
+ }
+ } else if Assets == nil {
+ return func(c *gin.Context) {
+ c.String(http.StatusNotFound, "404 Page not found - interface not embedded in binary, please compile with -tags web")
+ }
+ } else if forced_url == "/" {
+ // Serve altered index.html
+ return func(c *gin.Context) {
+ if indexTpl == nil {
+ // Create template from file
+ f, _ := Assets.Open("index.html")
+ v, _ := io.ReadAll(f)
+
+ v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1)
+
+ indexTpl = template.Must(template.New("index.html").Parse(v2))
+ }
+
+ // Serve template
+ if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
+ "Body": CustomBodyHTML,
+ "Head": CustomHeadHTML,
+ "RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
+ }); err != nil {
+ log.Println("Unable to return index.html:", err.Error())
+ }
+ }
+ } else if forced_url != "" {
+ // Serve forced_url
+ return func(c *gin.Context) {
+ c.FileFromFS(forced_url, Assets)
+ }
+ } else {
+ // Serve requested file
+ return func(c *gin.Context) {
+ if _, err := fs.Stat(_assets, path.Join("build", c.Request.URL.Path)); os.IsNotExist(err) {
+ c.FileFromFS("/404.html", Assets)
+ } else {
+ c.FileFromFS(c.Request.URL.Path, Assets)
+ }
+ }
+ }
+}
diff --git a/web/src/app.css b/web/src/app.css
new file mode 100644
index 0000000..dca80a5
--- /dev/null
+++ b/web/src/app.css
@@ -0,0 +1,166 @@
+:root {
+ --bs-primary: #1cb487;
+ --bs-primary-rgb: 28, 180, 135;
+ --bs-link-color-rgb: 28, 180, 135;
+ --bs-link-hover-color-rgb: 17, 112, 84;
+ --bs-tertiary-bg: #e7e8e8;
+}
+
+body {
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+
+.bg-tertiary {
+ background-color: var(--bs-tertiary-bg);
+}
+
+/* Animations */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.fade-in {
+ animation: fadeIn 0.6s ease-out;
+}
+
+.pulse {
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.spin {
+ animation: spin 1s linear infinite;
+}
+
+/* Score styling */
+.score-excellent {
+ color: #198754;
+}
+
+.score-good {
+ color: #20c997;
+}
+
+.score-warning {
+ color: #ffc107;
+}
+
+.score-poor {
+ color: #fd7e14;
+}
+
+.score-bad {
+ color: #dc3545;
+}
+
+/* Custom card styling */
+.card {
+ transition:
+ transform 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.card:not(.fade-in .card) {
+ border: none;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.fade-in .card:not(.card .card) {
+ border: none;
+}
+
+.card:hover:not(.fade-in .card) {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+}
+
+/* Check status badges */
+.check-status {
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.check-pass {
+ background-color: #d1e7dd;
+ color: #0f5132;
+}
+
+.check-fail {
+ background-color: #f8d7da;
+ color: #842029;
+}
+
+.check-warn {
+ background-color: #fff3cd;
+ color: #664d03;
+}
+
+.check-info {
+ background-color: #cfe2ff;
+ color: #084298;
+}
+
+/* Clipboard button */
+.clipboard-btn {
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.clipboard-btn:hover {
+ transform: scale(1.1);
+}
+
+.clipboard-btn:active {
+ transform: scale(0.95);
+}
+
+/* Progress bar animation */
+.progress-bar {
+ transition: width 0.6s ease;
+}
+
+/* Hero section */
+.hero {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+/* Feature icons */
+.feature-icon {
+ width: 4rem;
+ height: 4rem;
+ border-radius: 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ margin-bottom: 1rem;
+}
diff --git a/web/src/app.d.ts b/web/src/app.d.ts
new file mode 100644
index 0000000..d76242a
--- /dev/null
+++ b/web/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/web/src/app.html b/web/src/app.html
new file mode 100644
index 0000000..9e3bf88
--- /dev/null
+++ b/web/src/app.html
@@ -0,0 +1,40 @@
+
+
+