diff --git a/.drone.yml b/.drone.yml
index 053beb0..779952f 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -9,7 +9,7 @@ platform:
steps:
- name: frontend
- image: node:22-alpine
+ image: node:24-alpine
commands:
- cd web
- npm install --network-timeout=100000
@@ -21,7 +21,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
+ - go build -tags netgo -ldflags '-w -X 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
@@ -35,7 +35,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
+ - 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
@@ -47,7 +47,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
+ - 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
@@ -61,7 +61,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
+ - 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
diff --git a/.gitignore b/.gitignore
index 7ece05e..e943630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,5 +26,5 @@ logs/
*.sqlite3
# OpenAPI generated files
-internal/api/models.gen.go
-internal/api/server.gen.go
\ No newline at end of file
+internal/api/server.gen.go
+internal/model/types.gen.go
diff --git a/Dockerfile b/Dockerfile
index 36d7d33..60a4243 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
# Multi-stage Dockerfile for happyDeliver with integrated MTA
# Stage 1: Build the Svelte application
-FROM node:22-alpine AS nodebuild
+FROM node:24-alpine AS nodebuild
WORKDIR /build
@@ -31,19 +31,100 @@ COPY --from=nodebuild /build/web/build/ ./web/build/
RUN go generate ./... && \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
-# Stage 3: Runtime image with Postfix and all filters
+# 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-cryptx \
+ 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::DKIM && \
+ 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 apk add --no-cache \
+RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
+ apk add --no-cache \
bash \
ca-certificates \
- opendkim \
- opendkim-utils \
- opendmarc \
+ 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-cryptx \
+ 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 \
- postfix-policyd-spf-perl \
+ rspamd \
spamassassin \
spamassassin-client \
supervisor \
@@ -51,9 +132,8 @@ RUN apk add --no-cache \
tzdata \
&& rm -rf /var/cache/apk/*
-# Get test-only version of postfix-policyd-spf-perl
-ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
-RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl
+# Copy Mail::Milter::Authentication and its dependancies
+COPY --from=pl /usr/local/ /usr/local/
# Create happydeliver user and group
RUN addgroup -g 1000 happydeliver && \
@@ -63,12 +143,15 @@ RUN addgroup -g 1000 happydeliver && \
RUN mkdir -p /etc/happydeliver \
/var/lib/happydeliver \
/var/log/happydeliver \
- /var/spool/postfix/opendkim \
- /var/spool/postfix/opendmarc \
- /etc/opendkim/keys \
+ /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 opendkim:postfix /var/spool/postfix/opendkim \
- && chown -R opendmarc:postfix /var/spool/postfix/opendmarc
+ && 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
@@ -76,9 +159,9 @@ RUN chmod +x /usr/local/bin/happyDeliver
# Copy configuration files
COPY docker/postfix/ /etc/postfix/
-COPY docker/opendkim/ /etc/opendkim/
-COPY docker/opendmarc/ /etc/opendmarc/
+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
@@ -90,11 +173,21 @@ RUN chmod +x /entrypoint.sh
EXPOSE 25 8080
# Default configuration
-ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
+ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
+ HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
+ HAPPYDELIVER_DOMAIN=happydeliver.local \
+ HAPPYDELIVER_ADDRESS_PREFIX=test- \
+ HAPPYDELIVER_DNS_TIMEOUT=5s \
+ HAPPYDELIVER_HTTP_TIMEOUT=10s \
+ HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
# 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
index b9db23c..4010d7e 100644
--- a/README.md
+++ b/README.md
@@ -6,25 +6,27 @@ An open-source email deliverability testing platform that analyzes test emails a
## Features
-- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more
+- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
- **REST API**: Full-featured API for creating tests and retrieving reports
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
-- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
+- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
- **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, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
+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
-- **OpenDKIM**: DKIM signature verification
-- **OpenDMARC**: DMARC policy validation
+- **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
@@ -36,7 +38,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
cd happydeliver
# Edit docker-compose.yml to set your domain
-# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
+# Change HAPPYDELIVER_DOMAIN environment variable and hostname
# Build and start
docker-compose up -d
@@ -62,12 +64,86 @@ docker run -d \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
- -e HOSTNAME=mail.yourdomain.com \
+ --hostname mail.yourdomain.com \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
+#### 3. Configure 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
@@ -87,10 +163,27 @@ The server will start on `http://localhost:8080` by default.
#### 3. Integrate with your existing e-mail setup
-It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
+It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
-Choose one of the following way to integrate happyDeliver in your existing setup:
+#### Receiver Hostname
+
+happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
+
+If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
+
+```bash
+./happyDeliver server -receiver-hostname mail.example.com
+```
+
+Or via environment variable:
+```bash
+HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
+```
+
+**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
+
+If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
#### Postfix LMTP Transport
@@ -108,9 +201,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi
```
# Transport map - route test emails to happyDeliver LMTP server
- # Pattern: test-@yourdomain.com -> LMTP on localhost:2525
+ # Pattern: test-@yourdomain.com -> LMTP on localhost:2525
- /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
+ /^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`:
@@ -144,7 +237,7 @@ Response:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
- "email": "test-550e8400@localhost",
+ "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
"status": "pending",
"message": "Send your test email to the address above"
}
@@ -186,24 +279,43 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
+## Use with happyDomain
+
+happyDeliver can be driven by [happyDomain](https://happydomain.org) through
+the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver)
+plugin, so the deliverability of a domain you manage is monitored alongside
+its DNS and inbound SMTP posture.
+
+How it works:
+
+1. Attach the **Outbound deliverability** checker to the mail service of a zone
+ in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`;
+ operators can configure a default instance globally.
+2. On each run, the checker calls `POST /api/test` to allocate a fresh
+ recipient address, prompts the user (or an automated sender) to mail it from
+ the tested domain, then polls `GET /api/test/{id}` until the report is
+ ready.
+3. The structured report from `GET /api/report/{id}` is translated into
+ happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam
+ score, blacklists and headers, plus an overall score threshold
+ (`min_score`/`warn_score`).
+4. Runs repeat on a configurable interval so a regression in deliverability (a
+ new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...)
+ surfaces as a domain-level alert in happyDomain.
+
+See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver)
+for build instructions and the full list of run options.
+
## Scoring System
-The deliverability score is calculated from 0 to 10 based on:
+The deliverability score is calculated from A to F based on:
-- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
-- **Spam (2 pts)**: SpamAssassin score
-- **Blacklist (2 pts)**: RBL/DNSBL checks
-- **Content (2 pts)**: HTML quality, links, images, unsubscribe
-- **Headers (1 pt)**: Required headers, MIME structure
-
-**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor.
-
-**Ratings:**
-- 9-10: Excellent
-- 7-8.9: Good
-- 5-6.9: Fair
-- 3-4.9: Poor
-- 0-2.9: Critical
+- **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
diff --git a/api/config-models.yaml b/api/config-models.yaml
index 9c3425c..aa2fb0e 100644
--- a/api/config-models.yaml
+++ b/api/config-models.yaml
@@ -1,5 +1,9 @@
-package: api
+package: model
generate:
models: true
- embedded-spec: false
-output: internal/api/models.gen.go
+ embedded-spec: true
+output: internal/model/types.gen.go
+output-options:
+ skip-prune: true
+import-mapping:
+ ./schemas.yaml: "-"
diff --git a/api/config-server.yaml b/api/config-server.yaml
index 20f8daf..347dbaf 100644
--- a/api/config-server.yaml
+++ b/api/config-server.yaml
@@ -1,5 +1,8 @@
package: api
generate:
gin-server: true
+ models: true
embedded-spec: true
output: internal/api/server.gen.go
+import-mapping:
+ ./schemas.yaml: git.happydns.org/happyDeliver/internal/model
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 83151de..2dbf304 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -52,7 +52,7 @@ paths:
tags:
- tests
summary: Get test status
- description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
+ 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,7 +60,8 @@ paths:
required: true
schema:
type: string
- format: uuid
+ pattern: '^[a-z0-9-]+$'
+ description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Test status retrieved successfully
@@ -75,6 +76,49 @@ paths:
schema:
$ref: '#/components/schemas/Error'
+ /tests:
+ get:
+ tags:
+ - tests
+ summary: List all tests
+ description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration.
+ operationId: listTests
+ parameters:
+ - name: offset
+ in: query
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ description: Number of items to skip
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ default: 20
+ description: Maximum number of items to return
+ responses:
+ '200':
+ description: List of test summaries
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TestListResponse'
+ '403':
+ description: Test listing is disabled
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
/report/{id}:
get:
tags:
@@ -88,7 +132,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 +161,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 +177,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:
@@ -149,386 +296,74 @@ paths:
components:
schemas:
Test:
- type: object
- required:
- - id
- - email
- - status
- - created_at
- properties:
- id:
- type: string
- format: uuid
- description: Unique test identifier
- example: "550e8400-e29b-41d4-a716-446655440000"
- email:
- type: string
- format: email
- description: Unique test email address
- example: "test-550e8400@example.com"
- status:
- type: string
- 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
-
+ $ref: './schemas.yaml#/components/schemas/Test'
TestResponse:
- type: object
- required:
- - id
- - email
- - status
- properties:
- id:
- type: string
- format: uuid
- example: "550e8400-e29b-41d4-a716-446655440000"
- email:
- type: string
- format: email
- example: "test-550e8400@example.com"
- status:
- type: string
- enum: [pending]
- example: "pending"
- message:
- type: string
- example: "Send your test email to the address above"
-
+ $ref: './schemas.yaml#/components/schemas/TestResponse'
Report:
- type: object
- required:
- - id
- - test_id
- - score
- - checks
- - created_at
- properties:
- id:
- type: string
- format: uuid
- description: Report identifier
- test_id:
- type: string
- format: uuid
- description: Associated test ID
- score:
- type: number
- format: float
- minimum: 0
- maximum: 10
- description: Overall deliverability score (0-10)
- example: 8.5
- 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'
- blacklists:
- type: array
- items:
- $ref: '#/components/schemas/BlacklistCheck'
- raw_headers:
- type: string
- description: Raw email headers
- created_at:
- type: string
- format: date-time
-
+ $ref: './schemas.yaml#/components/schemas/Report'
ScoreSummary:
- type: object
- required:
- - authentication_score
- - spam_score
- - blacklist_score
- - content_score
- - header_score
- properties:
- authentication_score:
- type: number
- format: float
- minimum: 0
- maximum: 3
- description: SPF/DKIM/DMARC score (max 3 pts)
- example: 2.8
- spam_score:
- type: number
- format: float
- minimum: 0
- maximum: 2
- description: SpamAssassin score (max 2 pts)
- example: 1.5
- blacklist_score:
- type: number
- format: float
- 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
- header_score:
- type: number
- format: float
- minimum: 0
- maximum: 1
- description: Header quality score (max 1 pt)
- example: 0.9
-
- Check:
- type: object
- required:
- - category
- - name
- - status
- - score
- - message
- properties:
- category:
- 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)
- severity:
- type: string
- enum: [critical, high, medium, low, info]
- description: Issue severity
- example: "info"
- advice:
- type: string
- description: Remediation advice
- example: "Your DKIM configuration is correct"
-
+ $ref: './schemas.yaml#/components/schemas/ScoreSummary'
+ ContentAnalysis:
+ $ref: './schemas.yaml#/components/schemas/ContentAnalysis'
+ ContentIssue:
+ $ref: './schemas.yaml#/components/schemas/ContentIssue'
+ LinkCheck:
+ $ref: './schemas.yaml#/components/schemas/LinkCheck'
+ ImageCheck:
+ $ref: './schemas.yaml#/components/schemas/ImageCheck'
+ HeaderAnalysis:
+ $ref: './schemas.yaml#/components/schemas/HeaderAnalysis'
+ HeaderCheck:
+ $ref: './schemas.yaml#/components/schemas/HeaderCheck'
+ ReceivedHop:
+ $ref: './schemas.yaml#/components/schemas/ReceivedHop'
+ DKIMDomainInfo:
+ $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo'
+ DomainAlignment:
+ $ref: './schemas.yaml#/components/schemas/DomainAlignment'
+ HeaderIssue:
+ $ref: './schemas.yaml#/components/schemas/HeaderIssue'
AuthenticationResults:
- type: object
- properties:
- spf:
- $ref: '#/components/schemas/AuthResult'
- dkim:
- type: array
- items:
- $ref: '#/components/schemas/AuthResult'
- dmarc:
- $ref: '#/components/schemas/AuthResult'
- bimi:
- $ref: '#/components/schemas/AuthResult'
- arc:
- $ref: '#/components/schemas/ARCResult'
-
+ $ref: './schemas.yaml#/components/schemas/AuthenticationResults'
AuthResult:
- type: object
- required:
- - result
- properties:
- result:
- type: string
- enum: [pass, fail, none, neutral, softfail, temperror, permerror]
- description: Authentication result
- example: "pass"
- domain:
- type: string
- description: Domain being authenticated
- example: "example.com"
- selector:
- type: string
- description: DKIM selector (for DKIM only)
- example: "default"
- details:
- type: string
- description: Additional details about the result
-
+ $ref: './schemas.yaml#/components/schemas/AuthResult'
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"
-
+ $ref: './schemas.yaml#/components/schemas/ARCResult'
+ IPRevResult:
+ $ref: './schemas.yaml#/components/schemas/IPRevResult'
SpamAssassinResult:
- type: object
- required:
- - score
- - required_score
- - is_spam
- properties:
- score:
- type: number
- format: float
- description: SpamAssassin spam score
- example: 2.3
- required_score:
- type: number
- format: float
- description: Threshold for spam classification
- example: 5.0
- is_spam:
- type: boolean
- description: Whether message is classified as spam
- example: false
- tests:
- type: array
- items:
- type: string
- description: List of triggered SpamAssassin tests
- example: ["BAYES_00", "DKIM_SIGNED"]
- report:
- type: string
- description: Full SpamAssassin report
-
- DNSRecord:
- type: object
- required:
- - domain
- - record_type
- - status
- properties:
- domain:
- type: string
- description: Domain name
- example: "example.com"
- record_type:
- type: string
- enum: [MX, SPF, DKIM, DMARC, BIMI]
- description: DNS record type
- example: "SPF"
- status:
- type: string
- enum: [found, missing, invalid]
- description: Record status
- example: "found"
- value:
- type: string
- description: Record value
- example: "v=spf1 include:_spf.example.com ~all"
-
+ $ref: './schemas.yaml#/components/schemas/SpamAssassinResult'
+ SpamTestDetail:
+ $ref: './schemas.yaml#/components/schemas/SpamTestDetail'
+ RspamdResult:
+ $ref: './schemas.yaml#/components/schemas/RspamdResult'
+ DNSResults:
+ $ref: './schemas.yaml#/components/schemas/DNSResults'
+ MXRecord:
+ $ref: './schemas.yaml#/components/schemas/MXRecord'
+ SPFRecord:
+ $ref: './schemas.yaml#/components/schemas/SPFRecord'
+ DKIMRecord:
+ $ref: './schemas.yaml#/components/schemas/DKIMRecord'
+ DMARCRecord:
+ $ref: './schemas.yaml#/components/schemas/DMARCRecord'
+ BIMIRecord:
+ $ref: './schemas.yaml#/components/schemas/BIMIRecord'
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
- example: "zen.spamhaus.org"
- listed:
- type: boolean
- description: Whether IP is listed
- example: false
- response:
- type: string
- description: RBL response code or message
- example: "127.0.0.2"
-
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheck'
Status:
- type: object
- required:
- - status
- - version
- properties:
- status:
- type: string
- enum: [healthy, degraded, unhealthy]
- description: Overall service status
- example: "healthy"
- version:
- type: string
- description: Service version
- example: "0.1.0-dev"
- components:
- type: object
- properties:
- database:
- type: string
- enum: [up, down]
- example: "up"
- mta:
- type: string
- enum: [up, down]
- example: "up"
- uptime:
- type: integer
- description: Service uptime in seconds
- example: 3600
-
+ $ref: './schemas.yaml#/components/schemas/Status'
Error:
- type: object
- required:
- - error
- - message
- properties:
- error:
- type: string
- description: Error code
- example: "not_found"
- message:
- type: string
- description: Human-readable error message
- example: "Test not found"
- details:
- type: string
- description: Additional error details
+ $ref: './schemas.yaml#/components/schemas/Error'
+ DomainTestRequest:
+ $ref: './schemas.yaml#/components/schemas/DomainTestRequest'
+ DomainTestResponse:
+ $ref: './schemas.yaml#/components/schemas/DomainTestResponse'
+ BlacklistCheckRequest:
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest'
+ BlacklistCheckResponse:
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse'
+ TestSummary:
+ $ref: './schemas.yaml#/components/schemas/TestSummary'
+ TestListResponse:
+ $ref: './schemas.yaml#/components/schemas/TestListResponse'
diff --git a/api/schemas.yaml b/api/schemas.yaml
new file mode 100644
index 0000000..53aa297
--- /dev/null
+++ b/api/schemas.yaml
@@ -0,0 +1,1221 @@
+openapi: 3.0.3
+info:
+ title: happyDeliver Schemas
+ description: Shared schema definitions for happyDeliver
+ version: 0.1.0
+
+paths: {}
+
+components:
+ schemas:
+ Test:
+ type: object
+ required:
+ - id
+ - email
+ - status
+ properties:
+ id:
+ type: string
+ 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-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
+ status:
+ type: string
+ enum: [pending, analyzed]
+ description: Current test status (pending = no report yet, analyzed = report available)
+ example: "analyzed"
+
+ TestResponse:
+ type: object
+ required:
+ - id
+ - email
+ - status
+ properties:
+ id:
+ type: string
+ 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-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
+ status:
+ type: string
+ enum: [pending]
+ example: "pending"
+ message:
+ type: string
+ example: "Send your test email to the address above"
+
+ Report:
+ type: object
+ required:
+ - id
+ - test_id
+ - score
+ - grade
+ - created_at
+ properties:
+ id:
+ type: string
+ pattern: '^[a-z0-9-]+$'
+ description: Report identifier (base32-encoded with hyphens)
+ test_id:
+ type: string
+ pattern: '^[a-z0-9-]+$'
+ description: Associated test ID (base32-encoded with hyphens)
+ score:
+ type: integer
+ minimum: 0
+ 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'
+ authentication:
+ $ref: '#/components/schemas/AuthenticationResults'
+ spamassassin:
+ $ref: '#/components/schemas/SpamAssassinResult'
+ rspamd:
+ $ref: '#/components/schemas/RspamdResult'
+ dns_results:
+ $ref: '#/components/schemas/DNSResults'
+ blacklists:
+ 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
+ created_at:
+ type: string
+ format: date-time
+
+ ScoreSummary:
+ type: object
+ required:
+ - dns_score
+ - dns_grade
+ - authentication_score
+ - authentication_grade
+ - spam_score
+ - spam_grade
+ - blacklist_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: integer
+ minimum: 0
+ 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: integer
+ minimum: 0
+ 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: integer
+ minimum: 0
+ 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
+ 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"]
+
+ ContentIssue:
+ type: object
+ required:
+ - type
+ - severity
+ - message
+ properties:
+ type:
+ type: string
+ 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: "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: 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
+ properties:
+ spf:
+ $ref: '#/components/schemas/AuthResult'
+ dkim:
+ type: array
+ items:
+ $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
+ required:
+ - result
+ properties:
+ result:
+ type: string
+ enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
+ description: Authentication result
+ example: "pass"
+ domain:
+ type: string
+ description: Domain being authenticated
+ example: "example.com"
+ selector:
+ type: string
+ description: DKIM selector (for DKIM only)
+ example: "default"
+ details:
+ 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
+ description: SpamAssassin spam score
+ example: 2.3
+ required_score:
+ type: number
+ format: float
+ description: Threshold for spam classification
+ example: 5.0
+ is_spam:
+ type: boolean
+ description: Whether message is classified as spam
+ example: false
+ tests:
+ type: array
+ items:
+ 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
+
+ SpamTestDetail:
+ type: object
+ required:
+ - 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
+ params:
+ type: string
+ description: Symbol parameters or options
+ example: "0.02"
+ 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/SpamTestDetail'
+ 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)
+
+
+ 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: string
+ description: DKIM record content
+ example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."
+ key_type:
+ type: string
+ description: "Key type from k= tag (e.g. rsa, ed25519); defaults to rsa if absent"
+ example: "rsa"
+ hash_algorithms:
+ type: array
+ items:
+ type: string
+ description: "Acceptable hash algorithms from h= tag; empty means all accepted (RFC 6376 default: sha256)"
+ example: ["sha256"]
+ signing_algorithm:
+ type: string
+ description: "Algorithm used in DKIM-Signature a= tag (e.g. rsa-sha256, ed25519-sha256)"
+ example: "rsa-sha256"
+ key_size:
+ type: integer
+ description: "Public key size in bits (RSA: 1024/2048/4096; Ed25519: always 256)"
+ example: 2048
+ valid:
+ type: boolean
+ description: Whether the DKIM record is valid
+ example: true
+ error:
+ type: string
+ description: Error message if validation failed
+ example: "No DKIM record found"
+
+ DMARCRecord:
+ type: object
+ required:
+ - valid
+ properties:
+ record:
+ type: string
+ description: DMARC record content
+ example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
+ domain:
+ type: string
+ description: Domain at which the DMARC record was found (may differ from From domain when organizational domain fallback was used)
+ example: "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"
+ nonexistent_subdomain_policy:
+ type: string
+ enum: [none, quarantine, reject, unknown]
+ description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent (DMARCbis)
+ example: "reject"
+ percentage:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: "Percentage of messages subjected to filtering (pct tag, default 100). DEPRECATED in DMARCbis: use test_mode (t=y) instead."
+ example: 100
+ test_mode:
+ type: boolean
+ description: "DMARCbis t= tag: when true (t=y), receivers downgrade effective policy one level (reject→quarantine, quarantine→none). Replaces the deprecated pct= tag for testing."
+ example: false
+ psd:
+ type: string
+ enum: [y, n, u]
+ description: "DMARCbis psd= tag: y=this is a Public Suffix Domain, n=this is an Organizational Domain boundary, u=unknown (default, use DNS Tree Walk to determine)"
+ example: "u"
+ deprecated_pct:
+ type: boolean
+ description: "Whether the deprecated pct= tag was found in the record (pct is removed in DMARCbis; migrate to t=y for testing mode)"
+ example: false
+ deprecated_rf:
+ type: boolean
+ description: "Whether the deprecated rf= tag was found in the record (rf is removed in DMARCbis; failure report formats are now defined separately)"
+ example: false
+ deprecated_ri:
+ type: boolean
+ description: "Whether the deprecated ri= tag was found in the record (ri is removed in DMARCbis; aggregate reporting interval is now fixed at ≥24 hours)"
+ example: false
+ 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:
+ - rbl
+ - listed
+ properties:
+ rbl:
+ type: string
+ description: RBL/DNSBL name
+ example: "zen.spamhaus.org"
+ listed:
+ type: boolean
+ description: Whether IP is listed
+ example: false
+ response:
+ type: string
+ description: RBL response code or message
+ example: "127.0.0.2"
+ error:
+ type: string
+ description: RBL error if any
+
+ Status:
+ type: object
+ required:
+ - status
+ - version
+ properties:
+ status:
+ type: string
+ enum: [healthy, degraded, unhealthy]
+ description: Overall service status
+ example: "healthy"
+ version:
+ type: string
+ description: Service version
+ example: "0.1.0-dev"
+ components:
+ type: object
+ properties:
+ database:
+ type: string
+ enum: [up, down]
+ example: "up"
+ mta:
+ type: string
+ enum: [up, down]
+ example: "up"
+ uptime:
+ type: integer
+ description: Service uptime in seconds
+ example: 3600
+
+ Error:
+ type: object
+ required:
+ - error
+ - message
+ properties:
+ error:
+ type: string
+ description: Error code
+ example: "not_found"
+ message:
+ type: string
+ description: Human-readable error message
+ example: "Test not found"
+ 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)
+
+ TestSummary:
+ type: object
+ required:
+ - test_id
+ - score
+ - grade
+ - created_at
+ properties:
+ test_id:
+ type: string
+ pattern: '^[a-z0-9-]+$'
+ description: Test identifier (base32-encoded with hyphens)
+ score:
+ type: integer
+ minimum: 0
+ maximum: 100
+ description: Overall deliverability score (0-100)
+ grade:
+ type: string
+ enum: [A+, A, B, C, D, E, F]
+ description: Letter grade
+ from_domain:
+ type: string
+ description: Sender domain extracted from the report
+ created_at:
+ type: string
+ format: date-time
+
+ TestListResponse:
+ type: object
+ required:
+ - tests
+ - total
+ - offset
+ - limit
+ properties:
+ tests:
+ type: array
+ items:
+ $ref: '#/components/schemas/TestSummary'
+ total:
+ type: integer
+ description: Total number of tests
+ offset:
+ type: integer
+ description: Current offset
+ limit:
+ type: integer
+ description: Current limit
diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go
index 01d99f1..3caf4d1 100644
--- a/cmd/happyDeliver/main.go
+++ b/cmd/happyDeliver/main.go
@@ -29,13 +29,12 @@ import (
"git.happydns.org/happyDeliver/internal/app"
"git.happydns.org/happyDeliver/internal/config"
+ "git.happydns.org/happyDeliver/internal/version"
)
-const version = "0.1.0-dev"
-
func main() {
- fmt.Println("happyDeliver - Email Deliverability Testing Platform")
- fmt.Printf("Version: %s\n", version)
+ fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform")
+ fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version)
cfg, err := config.ConsolidateConfig()
if err != nil {
@@ -53,8 +52,20 @@ func main() {
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
log.Fatalf("Analyzer error: %v", err)
}
+ case "backup":
+ if err := app.RunBackup(cfg); err != nil {
+ log.Fatalf("Backup error: %v", err)
+ }
+ case "restore":
+ inputFile := ""
+ if len(flag.Args()) >= 2 {
+ inputFile = flag.Args()[1]
+ }
+ if err := app.RunRestore(cfg, inputFile); err != nil {
+ log.Fatalf("Restore error: %v", err)
+ }
case "version":
- fmt.Println(version)
+ fmt.Println(version.Version)
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
@@ -64,9 +75,11 @@ func main() {
func printUsage() {
fmt.Println("\nCommand availables:")
- fmt.Println(" happyDeliver server - Start the API server")
- fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
- fmt.Println(" happyDeliver version - Print version information")
+ fmt.Println(" happyDeliver server - Start the API server")
+ fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
+ fmt.Println(" happyDeliver 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
index 87521ef..dc34330 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,16 +1,30 @@
services:
+ unbound:
+ image: alpinelinux/unbound
+ restart: unless-stopped
+
+ configs:
+ - source: unbound_conf
+ target: /etc/unbound/unbound.conf
+ uid: "100"
+ gid: "101"
+
+ networks:
+ default:
+ ipv4_address: 172.28.0.53
+
happydeliver:
build:
context: .
dockerfile: Dockerfile
- image: happydeliver:latest
+ image: happydomain/happydeliver:latest
container_name: happydeliver
+ # Set a hostname
hostname: mail.happydeliver.local
environment:
- # Set your domain and hostname
- DOMAIN: happydeliver.local
- HOSTNAME: mail.happydeliver.local
+ # Set your domain
+ HAPPYDELIVER_DOMAIN: happydeliver.local
ports:
# SMTP port
@@ -24,15 +38,41 @@ services:
# Log files
- ./logs:/var/log/happydeliver
+ dns:
+ - 172.28.0.53
restart: unless-stopped
- healthcheck:
- test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 40s
+configs:
+ unbound_conf:
+ content: |
+ server:
+ verbosity: 1
+ interface: 0.0.0.0
+ port: 53
+ do-ip4: yes
+ do-ip6: no
+ do-udp: yes
+ do-tcp: yes
+
+ access-control: 127.0.0.0/8 allow
+ access-control: 172.28.0.0/24 allow
+
+ # Short cache for a testing resolver
+ cache-max-ttl: 60
+
+ # Buffers: let the system decide
+ so-sndbuf: 0
+ so-rcvbuf: 0
+
+ # Trust anchor (static, ships with the image)
+ trust-anchor-file: "/etc/unbound/root.key"
volumes:
data:
logs:
+
+networks:
+ default:
+ ipam:
+ config:
+ - subnet: 172.28.0.0/24
diff --git a/docker/README.md b/docker/README.md
index 45cce6b..2199eeb 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -109,12 +109,37 @@ Default configuration for the Docker environment:
The container accepts these environment variables:
-- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
-- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
+- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
+- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
+- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
+
+### Receiver Hostname
+
+happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
+
+In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
+
+**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
-Example:
```bash
-docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
+docker run -d \
+ -e HAPPYDELIVER_DOMAIN=example.com \
+ -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \
+ ...
+```
+
+To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`.
+
+If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
+
+Example (all-in-one, no override needed):
+```bash
+docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
+```
+
+Example (external MTA integration):
+```bash
+docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ...
```
## Volumes
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
index 445602d..ef45b61 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -4,34 +4,42 @@ set -e
echo "Starting happyDeliver container..."
# Get environment variables with defaults
-HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
+[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
echo "Hostname: $HOSTNAME"
echo "Domain: $HAPPYDELIVER_DOMAIN"
-# Create runtime directories
-mkdir -p /var/run/opendkim /var/run/opendmarc
-chown opendkim:postfix /var/run/opendkim
-chown opendmarc:postfix /var/run/opendmarc
-
# Create socket directories
-mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
-chown opendkim:postfix /var/spool/postfix/opendkim
-chown opendmarc:postfix /var/spool/postfix/opendmarc
-chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
+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
+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
-# Replace placeholders in OpenDMARC configuration
-sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf
+# 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
diff --git a/docker/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf
deleted file mode 100644
index 8fe2f8c..0000000
--- a/docker/opendkim/opendkim.conf
+++ /dev/null
@@ -1,39 +0,0 @@
-# OpenDKIM configuration for happyDeliver
-# Verifies DKIM signatures on incoming emails
-
-# Log to syslog
-Syslog yes
-SyslogSuccess yes
-LogWhy yes
-
-# Run as this user and group
-UserID opendkim:mail
-
-UMask 002
-
-# Socket for Postfix communication
-Socket unix:/var/spool/postfix/opendkim/opendkim.sock
-
-# Process ID file
-PidFile /var/run/opendkim/opendkim.pid
-
-# Operating mode - verify only (not signing)
-Mode v
-
-# Canonicalization methods
-Canonicalization relaxed/simple
-
-# DNS timeout
-DNSTimeout 5
-
-# Add header for verification results
-AlwaysAddARHeader yes
-
-# Accept unsigned mail
-On-NoSignature accept
-
-# Always add Authentication-Results header
-AlwaysAddARHeader yes
-
-# Maximum verification attempts
-MaximumSignaturesToVerify 3
diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf
deleted file mode 100644
index 882e11c..0000000
--- a/docker/opendmarc/opendmarc.conf
+++ /dev/null
@@ -1,41 +0,0 @@
-# OpenDMARC configuration for happyDeliver
-# Verifies DMARC policies on incoming emails
-
-# Socket for Postfix communication
-Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock
-
-# Process ID file
-PidFile /var/run/opendmarc/opendmarc.pid
-
-# Run as this user and group
-UserID opendmarc:mail
-
-UMask 002
-
-# Syslog configuration
-Syslog true
-SyslogFacility mail
-
-# Ignore authentication results from other hosts
-IgnoreAuthenticatedClients true
-
-# Accept mail even if DMARC fails (we're analyzing, not filtering)
-RejectFailures false
-
-# Trust Authentication-Results headers from localhost only
-TrustedAuthservIDs __HOSTNAME__
-
-# Add DMARC results to Authentication-Results header
-#AddAuthenticationResults true
-
-# DNS timeout
-DNSTimeout 5
-
-# History file (for reporting)
-# HistoryFile /var/spool/opendmarc/opendmarc.dat
-
-# Ignore hosts file
-# IgnoreHosts /etc/opendmarc/ignore.hosts
-
-# Public suffix list
-# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat
diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf
index 913eb57..5a73fb3 100644
--- a/docker/postfix/main.cf
+++ b/docker/postfix/main.cf
@@ -10,7 +10,7 @@ inet_interfaces = all
inet_protocols = ipv4
# Recipient settings
-mydestination = $myhostname, localhost.$mydomain, localhost
+mydestination = localhost.$mydomain, localhost
mynetworks = 127.0.0.0/8 [::1]/128
# Relay settings - accept mail for our test domain
@@ -28,14 +28,13 @@ transport_maps = pcre:/etc/postfix/transport_maps
# OpenDKIM for DKIM verification
milter_default_action = accept
milter_protocol = 6
-smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock
+smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
non_smtpd_milters = $smtpd_milters
# SPF policy checking
smtpd_recipient_restrictions =
permit_mynetworks,
- reject_unauth_destination,
- check_policy_service unix:private/policy-spf
+ reject_unauth_destination
# Logging
debug_peer_level = 2
diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf
index 92976a4..9c2ac57 100644
--- a/docker/postfix/master.cf
+++ b/docker/postfix/master.cf
@@ -2,7 +2,6 @@
# SMTP service
smtp inet n - n - - smtpd
- -o content_filter=spamassassin
# Pickup service
pickup unix n - n 60 1 pickup
@@ -74,10 +73,6 @@ scache unix - - n - 1 scache
maildrop unix - n n - - pipe
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
-# SPF policy service
-policy-spf unix - n n - 0 spawn
- user=nobody argv=/usr/bin/postfix-policyd-spf-perl
-
# SpamAssassin content filter
spamassassin unix - n n - - pipe
user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient}
diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps
index 49fdb98..cc1deed 100644
--- a/docker/postfix/transport_maps
+++ b/docker/postfix/transport_maps
@@ -1,4 +1,4 @@
# Transport map - route test emails to happyDeliver LMTP server
-# Pattern: test-@domain.com -> LMTP on localhost:2525
+# Pattern: test-@domain.com -> LMTP on localhost:2525
-/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525
+/^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
index c248ef6..ce9a31c 100644
--- a/docker/spamassassin/local.cf
+++ b/docker/spamassassin/local.cf
@@ -48,3 +48,14 @@ rbl_timeout 5
# Don't use user-specific rules
user_scores_dsn_timeout 3
user_scores_sql_override 0
+
+# Disable Validity network rules
+dns_query_restriction deny sa-trusted.bondedsender.org
+dns_query_restriction deny sa-accredit.habeas.com
+dns_query_restriction deny bl.score.senderscore.com
+score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
+score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
+score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
+score RCVD_IN_VALIDITY_CERTIFIED 0
+score RCVD_IN_VALIDITY_RPBL 0
+score RCVD_IN_VALIDITY_SAFE 0
\ No newline at end of file
diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf
index 1a0666e..74f1810 100644
--- a/docker/supervisor/supervisord.conf
+++ b/docker/supervisor/supervisord.conf
@@ -22,27 +22,26 @@ autostart=true
autorestart=true
priority=9
-# OpenDKIM service
-[program:opendkim]
-command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf
+# 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/opendkim.log
-stderr_logfile=/var/log/happydeliver/opendkim_error.log
-user=opendkim
+stdout_logfile=/var/log/happydeliver/authentication_milter.log
+stderr_logfile=/var/log/happydeliver/authentication_milter.log
+user=mail
group=mail
-# OpenDMARC service
-[program:opendmarc]
-command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf
+# 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/opendmarc.log
-stderr_logfile=/var/log/happydeliver/opendmarc_error.log
-user=opendmarc
-group=mail
+stdout_logfile=/var/log/happydeliver/rspamd.log
+stderr_logfile=/var/log/happydeliver/rspamd_error.log
+user=root
# SpamAssassin daemon
[program:spamd]
@@ -54,6 +53,18 @@ 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
diff --git a/generate.go b/generate.go
index d1ee5ab..324c52c 100644
--- a/generate.go
+++ b/generate.go
@@ -21,5 +21,5 @@
package main
-//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml
+//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
diff --git a/go.mod b/go.mod
index 7604b07..a975215 100644
--- a/go.mod
+++ b/go.mod
@@ -1,38 +1,42 @@
module git.happydns.org/happyDeliver
-go 1.24.6
+go 1.25.0
require (
+ github.com/JGLTechnologies/gin-rate-limit v1.5.8
github.com/emersion/go-smtp v0.24.0
- github.com/getkin/kin-openapi v0.132.0
- github.com/gin-gonic/gin v1.11.0
+ github.com/getkin/kin-openapi v0.138.0
+ github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
- github.com/oapi-codegen/runtime v1.1.2
- golang.org/x/net v0.46.0
+ github.com/oapi-codegen/runtime v1.4.0
+ golang.org/x/net v0.54.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
- gorm.io/gorm v1.31.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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
- github.com/gabriel-vasile/mimetype v1.4.8 // 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.7.6 // indirect
+ github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -40,34 +44,38 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/mailru/easyjson v0.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.32 // indirect
+ github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
- github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
- github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
- github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
+ github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect
+ github.com/oasdiff/yaml v0.0.9 // indirect
+ github.com/oasdiff/yaml3 v0.0.12 // 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.1 // indirect
- github.com/speakeasy-api/jsonpath v0.6.0 // indirect
- github.com/speakeasy-api/openapi-overlay v0.10.2 // 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.18.0 // indirect
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
+ github.com/speakeasy-api/jsonpath v0.6.3 // indirect
+ github.com/speakeasy-api/openapi v1.19.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.43.0 // indirect
- golang.org/x/mod v0.28.0 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/sys v0.37.0 // indirect
- golang.org/x/text v0.30.0 // indirect
- golang.org/x/tools v0.37.0 // indirect
- google.golang.org/protobuf v1.36.9 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
+ github.com/woodsbury/decimal128 v1.4.0 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/arch v0.23.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/mod v0.35.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/tools v0.44.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index bc46bc0..f4c8d28 100644
--- a/go.sum
+++ b/go.sum
@@ -1,19 +1,34 @@
+github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
+github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
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=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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=
@@ -24,33 +39,35 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/gabriel-vasile/mimetype v1.4.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.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
+github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
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/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+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=
@@ -76,8 +93,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
-github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -100,12 +117,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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
-github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
+github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -116,14 +133,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.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/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=
-github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
-github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
-github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
+github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0=
+github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw=
+github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
+github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec=
+github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
+github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
+github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
+github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@@ -140,52 +157,69 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
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.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
-github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
+github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
-github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
-github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
-github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
+github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
+github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
+github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M=
+github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.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=
+github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
+github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
+golang.org/x/arch v0.23.0/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
-golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
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=
@@ -193,13 +227,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
-golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
@@ -215,21 +249,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
-golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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=
@@ -242,8 +276,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=
@@ -265,5 +299,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
-gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 79d839e..de2d5df 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -31,21 +31,34 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config"
+ "git.happydns.org/happyDeliver/internal/model"
"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 *model.DNSResults, score int, grade string)
+ CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.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) *APIHandler {
+func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler {
return &APIHandler{
storage: store,
config: cfg,
+ analyzer: analyzer,
startTime: time.Now(),
}
}
@@ -56,79 +69,99 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
- // Generate test email address
+ // 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,
- testID.String(),
+ base32ID,
h.config.Email.Domain,
)
// Return response
- c.JSON(http.StatusCreated, TestResponse{
- Id: testID,
+ c.JSON(http.StatusCreated, model.TestResponse{
+ Id: base32ID,
Email: openapi_types.Email(email),
- Status: TestResponseStatusPending,
- Message: stringPtr("Send your test email to the address above"),
+ Status: model.TestResponseStatusPending,
+ Message: utils.PtrTo("Send your test email to the given address"),
})
}
// GetTest retrieves test metadata
// (GET /test/{id})
-func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
- // Check if a report exists for this test ID
- reportExists, err := h.storage.ReportExists(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.StatusInternalServerError, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
+ Error: "invalid_id",
+ Message: "Invalid test ID format",
+ Details: utils.PtrTo(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, model.Error{
Error: "internal_error",
Message: "Failed to check test status",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
// Determine status based on report existence
- var apiStatus TestStatus
+ var apiStatus model.TestStatus
if reportExists {
- apiStatus = TestStatusAnalyzed
+ apiStatus = model.TestStatusAnalyzed
} else {
- apiStatus = TestStatusPending
+ apiStatus = model.TestStatusPending
}
- // Generate test email address
+ // Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
- id.String(),
+ id,
h.config.Email.Domain,
)
- // Return current time for CreatedAt/UpdatedAt since we don't track tests anymore
- now := time.Now()
-
- c.JSON(http.StatusOK, Test{
- Id: id,
- Email: openapi_types.Email(email),
- Status: apiStatus,
- CreatedAt: now,
- UpdatedAt: &now,
+ c.JSON(http.StatusOK, model.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 openapi_types.UUID) {
- reportJSON, _, err := h.storage.GetReport(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, model.Error{
+ Error: "invalid_id",
+ Message: "Invalid test ID format",
+ Details: utils.PtrTo(err.Error()),
+ })
+ return
+ }
+
+ reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
- c.JSON(http.StatusNotFound, Error{
+ c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Report not found",
})
return
}
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve report",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -139,20 +172,31 @@ func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
-func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
- _, rawEmail, err := h.storage.GetReport(id)
+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, model.Error{
+ Error: "invalid_id",
+ Message: "Invalid test ID format",
+ Details: utils.PtrTo(err.Error()),
+ })
+ return
+ }
+
+ _, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
- c.JSON(http.StatusNotFound, Error{
+ c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Email not found",
})
return
}
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -160,6 +204,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
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, model.Error{
+ Error: "invalid_id",
+ Message: "Invalid test ID format",
+ Details: utils.PtrTo(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, model.Error{
+ Error: "not_found",
+ Message: "Email not found",
+ })
+ return
+ }
+ c.JSON(http.StatusInternalServerError, model.Error{
+ Error: "internal_error",
+ Message: "Failed to retrieve email",
+ Details: utils.PtrTo(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, model.Error{
+ Error: "analysis_error",
+ Message: "Failed to re-analyze email",
+ Details: utils.PtrTo(err.Error()),
+ })
+ return
+ }
+
+ // Update the report in storage
+ if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
+ c.JSON(http.StatusInternalServerError, model.Error{
+ Error: "internal_error",
+ Message: "Failed to update report",
+ Details: utils.PtrTo(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) {
@@ -167,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists
- dbStatus := StatusComponentsDatabaseUp
+ dbStatus := model.StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
- dbStatus = StatusComponentsDatabaseDown
+ dbStatus = model.StatusComponentsDatabaseDown
}
// Determine overall status
- overallStatus := Healthy
- if dbStatus == StatusComponentsDatabaseDown {
- overallStatus = Unhealthy
+ overallStatus := model.Healthy
+ if dbStatus == model.StatusComponentsDatabaseDown {
+ overallStatus = model.Unhealthy
}
- mtaStatus := StatusComponentsMtaUp
- c.JSON(http.StatusOK, Status{
+ mtaStatus := model.StatusComponentsMtaUp
+ c.JSON(http.StatusOK, model.Status{
Status: overallStatus,
- Version: "0.1.0-dev",
+ Version: version.Version,
Components: &struct {
- Database *StatusComponentsDatabase `json:"database,omitempty"`
- Mta *StatusComponentsMta `json:"mta,omitempty"`
+ Database *model.StatusComponentsDatabase `json:"database,omitempty"`
+ Mta *model.StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
@@ -192,3 +293,133 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
Uptime: &uptime,
})
}
+
+// TestDomain performs synchronous domain analysis
+// (POST /domain)
+func (h *APIHandler) TestDomain(c *gin.Context) {
+ var request model.DomainTestRequest
+
+ // Bind and validate request
+ if err := c.ShouldBindJSON(&request); err != nil {
+ c.JSON(http.StatusBadRequest, model.Error{
+ Error: "invalid_request",
+ Message: "Invalid request body",
+ Details: utils.PtrTo(err.Error()),
+ })
+ return
+ }
+
+ // Perform domain analysis
+ dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
+
+ // Convert grade string to DomainTestResponseGrade enum
+ var responseGrade model.DomainTestResponseGrade
+ switch grade {
+ case "A+":
+ responseGrade = model.DomainTestResponseGradeA
+ case "A":
+ responseGrade = model.DomainTestResponseGradeA1
+ case "B":
+ responseGrade = model.DomainTestResponseGradeB
+ case "C":
+ responseGrade = model.DomainTestResponseGradeC
+ case "D":
+ responseGrade = model.DomainTestResponseGradeD
+ case "E":
+ responseGrade = model.DomainTestResponseGradeE
+ case "F":
+ responseGrade = model.DomainTestResponseGradeF
+ default:
+ responseGrade = model.DomainTestResponseGradeF
+ }
+
+ // Build response
+ response := model.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 model.BlacklistCheckRequest
+
+ // Bind and validate request
+ if err := c.ShouldBindJSON(&request); err != nil {
+ c.JSON(http.StatusBadRequest, model.Error{
+ Error: "invalid_request",
+ Message: "Invalid request body",
+ Details: utils.PtrTo(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, model.Error{
+ Error: "invalid_ip",
+ Message: "Invalid IP address",
+ Details: utils.PtrTo(err.Error()),
+ })
+ return
+ }
+
+ // Build response
+ response := model.BlacklistCheckResponse{
+ Ip: request.Ip,
+ Blacklists: checks,
+ Whitelists: &whitelists,
+ ListedCount: listedCount,
+ Score: score,
+ Grade: model.BlacklistCheckResponseGrade(grade),
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+// ListTests returns a paginated list of test summaries
+// (GET /tests)
+func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
+ if h.config.DisableTestList {
+ c.JSON(http.StatusForbidden, model.Error{
+ Error: "feature_disabled",
+ Message: "Test listing is disabled on this instance",
+ })
+ return
+ }
+
+ offset := 0
+ limit := 20
+ if params.Offset != nil {
+ offset = *params.Offset
+ }
+ if params.Limit != nil {
+ limit = *params.Limit
+ if limit > 100 {
+ limit = 100
+ }
+ }
+
+ tests, total, err := h.storage.ListReportSummaries(offset, limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, model.Error{
+ Error: "internal_error",
+ Message: "Failed to list tests",
+ Details: utils.PtrTo(err.Error()),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, model.TestListResponse{
+ Tests: tests,
+ Total: int(total),
+ Offset: offset,
+ Limit: limit,
+ })
+}
diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go
index 2cccf1b..c704c56 100644
--- a/internal/app/cli_analyzer.go
+++ b/internal/app/cli_analyzer.go
@@ -31,7 +31,6 @@ import (
"github.com/google/uuid"
- "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
@@ -87,57 +86,552 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
// outputHumanReadable outputs a human-readable summary
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
- // Header
+ 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
- summary := emailAnalyzer.GetScoreSummaryText(result)
- fmt.Fprintln(writer, summary)
+ // Score Summary
+ if report.Summary != nil {
+ fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
+ fmt.Fprintln(writer, "SCORE BREAKDOWN")
+ fmt.Fprintln(writer, strings.Repeat("-", 70))
- // Detailed checks
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
-
- // Group checks by category
- categories := make(map[api.CheckCategory][]api.Check)
- for _, check := range result.Report.Checks {
- categories[check.Category] = append(categories[check.Category], check)
+ 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)
}
- // Print checks by category
- categoryOrder := []api.CheckCategory{
- api.Authentication,
- api.Dns,
- api.Blacklist,
- api.Content,
- api.Headers,
- }
+ // DNS Results
+ if report.DnsResults != nil {
+ fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
+ fmt.Fprintln(writer, "DNS CONFIGURATION")
+ fmt.Fprintln(writer, strings.Repeat("-", 70))
- for _, category := range categoryOrder {
- checks, ok := categories[category]
- if !ok || len(checks) == 0 {
- continue
+ 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)
}
- fmt.Fprintf(writer, "\n%s:\n", category)
- for _, check := range checks {
- statusSymbol := "✓"
- if check.Status == api.CheckStatusFail {
- statusSymbol = "✗"
- } else if check.Status == api.CheckStatusWarn {
- statusSymbol = "⚠"
+ // MX Records
+ if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 {
+ fmt.Fprintln(writer, "\n MX Records (From Domain):")
+ for _, mx := range *dns.FromMxRecords {
+ status := "✓"
+ if !mx.Valid {
+ status = "✗"
+ }
+ fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host)
+ if mx.Error != nil {
+ fmt.Fprintf(writer, " - ERROR: %s", *mx.Error)
+ }
+ fmt.Fprintln(writer)
}
+ }
- fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
- if check.Advice != nil && *check.Advice != "" {
- fmt.Fprintf(writer, " → %s\n", *check.Advice)
+ // 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)
+ }
+ if dns.DmarcRecord.NonexistentSubdomainPolicy != nil {
+ fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy)
+ }
+ 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
index 332516b..7149f45 100644
--- a/internal/app/server.go
+++ b/internal/app/server.go
@@ -25,13 +25,16 @@ 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"
)
@@ -63,8 +66,11 @@ func RunServer(cfg *config.Config) error {
}
}()
+ // Create analyzer adapter for API
+ analyzerAdapter := analyzer.NewAPIAdapter(cfg)
+
// Create API handler
- handler := api.NewAPIHandler(store, cfg)
+ handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
// Set up Gin router
if os.Getenv("GIN_MODE") == "" {
@@ -72,8 +78,30 @@ func RunServer(cfg *config.Config) error {
}
router := gin.Default()
- // Register API routes
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)
diff --git a/internal/config/cli.go b/internal/config/cli.go
index 93c18ce..fcc914f 100644
--- a/internal/config/cli.go
+++ b/internal/config/cli.go
@@ -34,10 +34,17 @@ func declareFlags(o *Config) {
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.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())")
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
flag.DurationVar(&o.Analysis.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.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
+ flag.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")
+ flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 510aaa9..b264994 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -25,6 +25,7 @@ import (
"flag"
"fmt"
"log"
+ "net/url"
"os"
"path"
"strings"
@@ -33,6 +34,11 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
+func getHostname() string {
+ h, _ := os.Hostname()
+ return h
+}
+
// Config represents the application configuration
type Config struct {
DevProxy string
@@ -41,6 +47,10 @@ type Config struct {
Email EmailConfig
Analysis AnalysisConfig
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
+ RateLimit uint // API rate limit (requests per second per IP)
+ SurveyURL url.URL // URL for user feedback survey
+ CustomLogoURL string // URL for custom logo image in the web UI
+ DisableTestList bool // Disable the public test listing endpoint
}
// DatabaseConfig contains database connection settings
@@ -54,13 +64,17 @@ type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
+ ReceiverHostname string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
type AnalysisConfig struct {
- DNSTimeout time.Duration
- HTTPTimeout time.Duration
- RBLs []string
+ DNSTimeout time.Duration
+ HTTPTimeout time.Duration
+ RBLs []string
+ DNSWLs []string
+ CheckAllIPs bool // Check all IPs found in headers, not just the first one
+ RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
}
// DefaultConfig returns a configuration with sensible defaults
@@ -69,6 +83,7 @@ func DefaultConfig() *Config {
DevProxy: "",
Bind: ":8080",
ReportRetention: 0, // Keep reports forever by default
+ RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default)
Database: DatabaseConfig{
Type: "sqlite",
DSN: "happydeliver.db",
@@ -77,11 +92,14 @@ func DefaultConfig() *Config {
Domain: "happydeliver.local",
TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525",
+ ReceiverHostname: getHostname(),
},
Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second,
RBLs: []string{},
+ DNSWLs: []string{},
+ CheckAllIPs: false, // By default, only check the first IP
},
}
}
diff --git a/internal/config/custom.go b/internal/config/custom.go
index 9461632..97c8d71 100644
--- a/internal/config/custom.go
+++ b/internal/config/custom.go
@@ -23,6 +23,7 @@ package config
import (
"fmt"
+ "net/url"
"strings"
)
@@ -43,3 +44,25 @@ func (i *StringArray) Set(value string) error {
return nil
}
+
+type URL struct {
+ URL *url.URL
+}
+
+func (i *URL) String() string {
+ if i.URL != nil {
+ return i.URL.String()
+ } else {
+ return ""
+ }
+}
+
+func (i *URL) Set(value string) error {
+ u, err := url.Parse(value)
+ if err != nil {
+ return err
+ }
+
+ *i.URL = *u
+ return nil
+}
diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go
index 1d9a720..a9b36b9 100644
--- a/internal/lmtp/server.go
+++ b/internal/lmtp/server.go
@@ -92,6 +92,10 @@ func (s *Session) Data(r io.Reader) error {
log.Printf("LMTP: Received %d bytes", len(emailData))
+ // Prepend Return-Path header from envelope sender
+ returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from)
+ emailData = append([]byte(returnPath), emailData...)
+
// Process email for each recipient
// LMTP requires per-recipient status, but go-smtp handles this internally
for _, recipient := range s.recipients {
diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go
index 1132b54..f06f535 100644
--- a/internal/receiver/receiver.go
+++ b/internal/receiver/receiver.go
@@ -22,6 +22,7 @@
package receiver
import (
+ "encoding/base32"
"encoding/json"
"fmt"
"io"
@@ -95,7 +96,18 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return fmt.Errorf("failed to analyze email: %w", err)
}
- log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
+ log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
+
+ // Warn if the last Received hop doesn't match the expected receiver hostname
+ if r.config.Email.ReceiverHostname != "" &&
+ result.Report.HeaderAnalysis != nil &&
+ result.Report.HeaderAnalysis.ReceivedChain != nil &&
+ len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 {
+ lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0]
+ if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname {
+ log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname)
+ }
+ }
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
@@ -112,8 +124,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return nil
}
+// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
+// Hyphens are ignored during decoding
+func base32ToUUID(encoded string) (uuid.UUID, error) {
+ // Remove hyphens for decoding
+ encoded = strings.ReplaceAll(encoded, "-", "")
+
+ // Convert to uppercase for Base32 decoding
+ encoded = strings.ToUpper(encoded)
+
+ // Decode from Base32
+ decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
+ if err != nil {
+ return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
+ }
+
+ // Ensure we have exactly 16 bytes for UUID
+ if len(decoded) != 16 {
+ return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
+ }
+
+ // Convert bytes to UUID
+ var id uuid.UUID
+ copy(id[:], decoded)
+ return id, nil
+}
+
// extractTestID extracts the UUID from the test email address
-// Expected format: test-@domain.com
+// 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, "<>")
@@ -133,10 +171,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
- // Parse UUID
- testID, err := uuid.Parse(uuidStr)
+ // Decode Base32 to UUID
+ testID, err := base32ToUUID(uuidStr)
if err != nil {
- return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
+ return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
}
return testID, nil
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 7c27279..86605df 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -30,6 +30,9 @@ import (
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
var (
@@ -43,7 +46,9 @@ type Storage interface {
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
ReportExists(testID uuid.UUID) (bool, error)
+ UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error)
+ ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
// Close closes the database connection
Close() error
@@ -107,7 +112,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
var dbReport Report
- if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil {
+ 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
}
@@ -117,6 +122,18 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
return dbReport.ReportJSON, dbReport.RawEmail, nil
}
+// UpdateReport updates the report JSON for an existing test ID
+func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error {
+ result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON)
+ if result.Error != nil {
+ return fmt.Errorf("failed to update report: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
// DeleteOldReports deletes reports older than the specified time
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
@@ -126,6 +143,72 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
return result.RowsAffected, nil
}
+// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
+type reportSummaryRow struct {
+ TestID uuid.UUID
+ Score int
+ Grade string
+ FromDomain string
+ CreatedAt time.Time
+}
+
+// ListReportSummaries returns a paginated list of lightweight report summaries
+func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
+ var total int64
+ if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count reports: %w", err)
+ }
+
+ if total == 0 {
+ return []model.TestSummary{}, 0, nil
+ }
+
+ var selectExpr string
+ switch s.db.Dialector.Name() {
+ case "postgres":
+ selectExpr = `test_id, ` +
+ `(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
+ `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
+ `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
+ `created_at`
+ case "sqlite":
+ selectExpr = `test_id, ` +
+ `json_extract(report_json, '$.score') as score, ` +
+ `json_extract(report_json, '$.grade') as grade, ` +
+ `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
+ `created_at`
+ default:
+ return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
+ }
+
+ var rows []reportSummaryRow
+ err := s.db.Model(&Report{}).
+ Select(selectExpr).
+ Order("created_at DESC").
+ Offset(offset).
+ Limit(limit).
+ Scan(&rows).Error
+ if err != nil {
+ return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
+ }
+
+ summaries := make([]model.TestSummary, 0, len(rows))
+ for _, r := range rows {
+ s := model.TestSummary{
+ TestId: utils.UUIDToBase32(r.TestID),
+ Score: r.Score,
+ Grade: model.TestSummaryGrade(r.Grade),
+ CreatedAt: r.CreatedAt,
+ }
+ if r.FromDomain != "" {
+ s.FromDomain = utils.PtrTo(r.FromDomain)
+ }
+ summaries = append(summaries, s)
+ }
+
+ return summaries, total, nil
+}
+
// Close closes the database connection
func (s *DBStorage) Close() error {
sqlDB, err := s.db.DB()
@@ -134,3 +217,33 @@ func (s *DBStorage) Close() error {
}
return sqlDB.Close()
}
+
+// GetAllReports retrieves all reports from the database
+func GetAllReports(s Storage) ([]Report, error) {
+ dbStorage, ok := s.(*DBStorage)
+ if !ok {
+ return nil, fmt.Errorf("storage type does not support GetAllReports")
+ }
+
+ var reports []Report
+ if err := dbStorage.db.Find(&reports).Error; err != nil {
+ return nil, fmt.Errorf("failed to retrieve reports: %w", err)
+ }
+
+ return reports, nil
+}
+
+// CreateReportFromBackup creates a report from backup data, preserving timestamps
+func CreateReportFromBackup(s Storage, report *Report) (*Report, error) {
+ dbStorage, ok := s.(*DBStorage)
+ if !ok {
+ return nil, fmt.Errorf("storage type does not support CreateReportFromBackup")
+ }
+
+ // Use Create to insert the report with all fields including timestamps
+ if err := dbStorage.db.Create(report).Error; err != nil {
+ return nil, fmt.Errorf("failed to create report from backup: %w", err)
+ }
+
+ return report, nil
+}
diff --git a/internal/api/helpers.go b/internal/utils/ptr.go
similarity index 91%
rename from internal/api/helpers.go
rename to internal/utils/ptr.go
index cce306a..748d6ba 100644
--- a/internal/api/helpers.go
+++ b/internal/utils/ptr.go
@@ -1,5 +1,5 @@
// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
+// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@@ -19,11 +19,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package api
-
-func stringPtr(s string) *string {
- return &s
-}
+package utils
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {
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
index 3588280..5f57df3 100644
--- a/pkg/analyzer/analyzer.go
+++ b/pkg/analyzer/analyzer.go
@@ -23,11 +23,12 @@ package analyzer
import (
"bytes"
+ "encoding/json"
"fmt"
"github.com/google/uuid"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/config"
)
@@ -40,9 +41,13 @@ type EmailAnalyzer struct {
// NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator(
+ cfg.Email.ReceiverHostname,
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
+ cfg.Analysis.DNSWLs,
+ cfg.Analysis.CheckAllIPs,
+ cfg.Analysis.RspamdAPIURL,
)
return &EmailAnalyzer{
@@ -54,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
- Report *api.Report
+ Report *model.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
@@ -78,10 +83,68 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A
}, nil
}
-// GetScoreSummaryText returns a human-readable score summary
-func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
- if result == nil || result.Results == nil {
- return ""
- }
- return a.generator.GetScoreSummaryText(result.Results)
+// 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) (*model.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) ([]model.BlacklistCheck, []model.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][]model.BlacklistCheck{ip: checks},
+ IPsChecked: []string{ip},
+ ListedCount: listedCount,
+ }
+ score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
+
+ // 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
index d6fd600..bd8880d 100644
--- a/pkg/analyzer/authentication.go
+++ b/pkg/analyzer/authentication.go
@@ -22,27 +22,27 @@
package analyzer
import (
- "fmt"
- "regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
// AuthenticationAnalyzer analyzes email authentication results
-type AuthenticationAnalyzer struct{}
+type AuthenticationAnalyzer struct {
+ receiverHostname string
+}
// NewAuthenticationAnalyzer creates a new authentication analyzer
-func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
- return &AuthenticationAnalyzer{}
+func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
+ return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
-func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
- results := &api.AuthenticationResults{}
+func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
+ results := &model.AuthenticationResults{}
// Parse Authentication-Results headers
- authHeaders := email.GetAuthenticationResults()
+ authHeaders := email.GetAuthenticationResults(a.receiverHostname)
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
@@ -52,13 +52,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
results.Spf = a.parseLegacySPF(email)
}
- if results.Dkim == nil || len(*results.Dkim) == 0 {
- dkimResults := a.parseLegacyDKIM(email)
- if len(dkimResults) > 0 {
- results.Dkim = &dkimResults
- }
- }
-
// Parse ARC headers if not already parsed from Authentication-Results
if results.Arc == nil {
results.Arc = a.parseARCHeaders(email)
@@ -72,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
// 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) {
+func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
@@ -98,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
- dkimList := []api.AuthResult{*dkimResult}
+ dkimList := []model.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
@@ -126,382 +119,67 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.Arc = a.parseARCResult(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
-}
-
-// parseBIMIResult parses BIMI result from Authentication-Results
-// Example: bimi=pass header.d=example.com header.selector=default
-func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
- result := &api.AuthResult{}
-
- // Extract result (pass, fail, etc.)
- re := regexp.MustCompile(`bimi=(\w+)`)
- if matches := re.FindStringSubmatch(part); len(matches) > 1 {
- resultStr := strings.ToLower(matches[1])
- result.Result = api.AuthResultResult(resultStr)
- }
-
- // Extract domain (header.d or d)
- domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
- if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
- domain := matches[1]
- result.Domain = &domain
- }
-
- // Extract selector (header.selector or selector)
- selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
- if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
- selector := matches[1]
- result.Selector = &selector
- }
-
- // Extract details
- if idx := strings.Index(part, "("); idx != -1 {
- endIdx := strings.Index(part[idx:], ")")
- if endIdx != -1 {
- details := strings.TrimSpace(part[idx+1 : idx+endIdx])
- result.Details = &details
- }
- }
-
- return result
-}
-
-// parseARCResult parses ARC result from Authentication-Results
-// Example: arc=pass
-func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
- result := &api.ARCResult{}
-
- // Extract result (pass, fail, none)
- re := regexp.MustCompile(`arc=(\w+)`)
- if matches := re.FindStringSubmatch(part); len(matches) > 1 {
- resultStr := strings.ToLower(matches[1])
- result.Result = api.ARCResultResult(resultStr)
- }
-
- // Extract details
- if idx := strings.Index(part, "("); idx != -1 {
- endIdx := strings.Index(part[idx:], ")")
- if endIdx != -1 {
- details := strings.TrimSpace(part[idx+1 : idx+endIdx])
- result.Details = &details
- }
- }
-
- return result
-}
-
-// parseARCHeaders parses ARC headers from email message
-// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
-func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
- // Get all ARC-related headers
- arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
- arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
- arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
-
- // If no ARC headers present, return nil
- if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
- return nil
- }
-
- result := &api.ARCResult{
- Result: api.ARCResultResultNone,
- }
-
- // Count the ARC chain length (number of sets)
- chainLength := len(arcSeal)
- result.ChainLength = &chainLength
-
- // Validate the ARC chain
- chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
- result.ChainValid = &chainValid
-
- // Determine overall result
- if chainLength == 0 {
- result.Result = api.ARCResultResultNone
- details := "No ARC chain present"
- result.Details = &details
- } else if !chainValid {
- result.Result = api.ARCResultResultFail
- details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
- result.Details = &details
- } else {
- result.Result = api.ARCResultResultPass
- details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
- result.Details = &details
- }
-
- return result
-}
-
-// enhanceARCResult enhances an existing ARC result with chain information
-func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
- if arcResult == nil {
- return
- }
-
- // Get ARC headers
- arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
- arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
- arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
-
- // Set chain length if not already set
- if arcResult.ChainLength == nil {
- chainLength := len(arcSeal)
- arcResult.ChainLength = &chainLength
- }
-
- // Validate chain if not already validated
- if arcResult.ChainValid == nil {
- chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
- arcResult.ChainValid = &chainValid
- }
-}
-
-// validateARCChain validates the ARC chain for completeness
-// Each instance should have all three headers with matching instance numbers
-func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
- // All three header types should have the same count
- if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
- return false
- }
-
- if len(arcSeal) == 0 {
- return true // No ARC chain is technically valid
- }
-
- // Extract instance numbers from each header type
- sealInstances := a.extractARCInstances(arcSeal)
- sigInstances := a.extractARCInstances(arcMessageSig)
- authInstances := a.extractARCInstances(arcAuthResults)
-
- // Check that all instance numbers match and are sequential starting from 1
- if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
- return false
- }
-
- // Verify instances are sequential from 1 to N
- for i := 1; i <= len(sealInstances); i++ {
- if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
- return false
- }
- }
-
- return true
-}
-
-// extractARCInstances extracts instance numbers from ARC headers
-func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
- var instances []int
- re := regexp.MustCompile(`i=(\d+)`)
-
- for _, header := range headers {
- if matches := re.FindStringSubmatch(header); len(matches) > 1 {
- var instance int
- fmt.Sscanf(matches[1], "%d", &instance)
- instances = append(instances, instance)
- }
- }
-
- return instances
-}
-
-// contains checks if a slice contains an integer
-func contains(slice []int, val int) bool {
- for _, item := range slice {
- if item == val {
- return true
- }
- }
- return false
-}
-
-// pluralize returns "y" or "ies" based on count
-func pluralize(count int) string {
- if count == 1 {
- return "y"
- }
- return "ies"
-}
-
-// parseLegacySPF attempts to parse SPF from Received-SPF header
-func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
- receivedSPF := email.Header.Get("Received-SPF")
- if receivedSPF == "" {
- return nil
- }
-
- result := &api.AuthResult{}
-
- // Extract result (first word)
- parts := strings.Fields(receivedSPF)
- if len(parts) > 0 {
- resultStr := strings.ToLower(parts[0])
- result.Result = api.AuthResultResult(resultStr)
- }
-
- // Try to extract domain
- domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
- if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
- email := matches[1]
- if idx := strings.Index(email, "@"); idx != -1 {
- domain := email[idx+1:]
- result.Domain = &domain
- }
- }
-
- return result
-}
-
-// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header
-func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult {
- var results []api.AuthResult
-
- // Get all DKIM-Signature headers
- dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")]
- for _, dkimHeader := range dkimHeaders {
- result := api.AuthResult{
- Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone
+ // Parse IPRev
+ if strings.HasPrefix(part, "iprev=") {
+ if results.Iprev == nil {
+ results.Iprev = a.parseIPRevResult(part)
+ }
}
- // Extract domain (d=)
- domainRe := regexp.MustCompile(`d=([^\s;]+)`)
- if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
- domain := matches[1]
- result.Domain = &domain
+ // Parse x-google-dkim
+ if strings.HasPrefix(part, "x-google-dkim=") {
+ if results.XGoogleDkim == nil {
+ results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
+ }
}
- // 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:])
+ // Parse x-aligned-from
+ if strings.HasPrefix(part, "x-aligned-from=") {
+ if results.XAlignedFrom == nil {
+ results.XAlignedFrom = a.parseXAlignedFromResult(part)
+ }
}
}
- return strings.Join(words, "-")
+}
+
+// CalculateAuthenticationScore calculates the authentication score from auth results
+// Returns a score from 0-100 where higher is better
+func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
+ if results == nil {
+ return 0, ""
+ }
+
+ score := 0
+
+ // Core authentication (90 points total)
+ // SPF (30 points)
+ score += 30 * a.calculateSPFScore(results) / 100
+
+ // DKIM (30 points)
+ score += 30 * a.calculateDKIMScore(results) / 100
+
+ // DMARC (30 points)
+ score += 30 * a.calculateDMARCScore(results) / 100
+
+ // BIMI (10 points)
+ score += 10 * a.calculateBIMIScore(results) / 100
+
+ // Penalty-only: IPRev (up to -7 points on failure)
+ if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
+ score += 7 * (iprevScore - 100) / 100
+ }
+
+ // Penalty-only: X-Google-DKIM (up to -12 points on failure)
+ score += 12 * a.calculateXGoogleDKIMScore(results) / 100
+
+ // Penalty-only: X-Aligned-From (up to -5 points on failure)
+ score += 5 * a.calculateXAlignedFromScore(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..e7333ce
--- /dev/null
+++ b/pkg/analyzer/authentication_arc.go
@@ -0,0 +1,184 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// 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) *model.ARCResult {
+ result := &model.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 = model.ARCResultResult(resultStr)
+ }
+
+ result.Details = utils.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) *model.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 := &model.ARCResult{
+ Result: model.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 = model.ARCResultResultNone
+ details := "No ARC chain present"
+ result.Details = &details
+ } else if !chainValid {
+ result.Result = model.ARCResultResultFail
+ details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
+ result.Details = &details
+ } else {
+ result.Result = model.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 *model.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..ac51d0b
--- /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/model"
+)
+
+func TestParseARCResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.ARCResultResult
+ }{
+ {
+ name: "ARC pass",
+ part: "arc=pass",
+ expectedResult: model.ARCResultResultPass,
+ },
+ {
+ name: "ARC fail",
+ part: "arc=fail",
+ expectedResult: model.ARCResultResultFail,
+ },
+ {
+ name: "ARC none",
+ part: "arc=none",
+ expectedResult: model.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..9654ac7
--- /dev/null
+++ b/pkg/analyzer/authentication_bimi.go
@@ -0,0 +1,76 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// parseBIMIResult parses BIMI result from Authentication-Results
+// Example: bimi=pass header.d=example.com header.selector=default
+func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult {
+ result := &model.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 = model.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 = utils.PtrTo(strings.TrimPrefix(part, "bimi="))
+
+ return result
+}
+
+func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) {
+ if results.Bimi != nil {
+ switch results.Bimi.Result {
+ case model.AuthResultResultPass:
+ return 100
+ case model.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..440f356
--- /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/model"
+)
+
+func TestParseBIMIResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.AuthResultResult
+ expectedDomain string
+ expectedSelector string
+ }{
+ {
+ name: "BIMI pass with domain and selector",
+ part: "bimi=pass header.d=example.com header.selector=default",
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: "example.com",
+ expectedSelector: "default",
+ },
+ {
+ name: "BIMI fail",
+ part: "bimi=fail header.d=example.com header.selector=default",
+ expectedResult: model.AuthResultResultFail,
+ expectedDomain: "example.com",
+ expectedSelector: "default",
+ },
+ {
+ name: "BIMI with short form (d= and selector=)",
+ part: "bimi=pass d=example.com selector=v1",
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: "example.com",
+ expectedSelector: "v1",
+ },
+ {
+ name: "BIMI none",
+ part: "bimi=none header.d=example.com",
+ expectedResult: model.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_checks.go b/pkg/analyzer/authentication_checks.go
deleted file mode 100644
index 01298a0..0000000
--- a/pkg/analyzer/authentication_checks.go
+++ /dev/null
@@ -1,304 +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"
-
- "git.happydns.org/happyDeliver/internal/api"
-)
-
-// GenerateAuthenticationChecks generates check results for authentication
-func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
- var checks []api.Check
-
- // SPF check
- if results.Spf != nil {
- check := a.generateSPFCheck(results.Spf)
- checks = append(checks, check)
- } else {
- checks = append(checks, api.Check{
- Category: api.Authentication,
- Name: "SPF Record",
- Status: api.CheckStatusWarn,
- Score: 0.0,
- Message: "No SPF authentication result found",
- Severity: api.PtrTo(api.CheckSeverityMedium),
- Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
- })
- }
-
- // DKIM check
- if results.Dkim != nil && len(*results.Dkim) > 0 {
- for i, dkim := range *results.Dkim {
- check := a.generateDKIMCheck(&dkim, i)
- checks = append(checks, check)
- }
- } else {
- checks = append(checks, api.Check{
- Category: api.Authentication,
- Name: "DKIM Signature",
- Status: api.CheckStatusWarn,
- Score: 0.0,
- Message: "No DKIM signature found",
- Severity: api.PtrTo(api.CheckSeverityMedium),
- Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
- })
- }
-
- // DMARC check
- if results.Dmarc != nil {
- check := a.generateDMARCCheck(results.Dmarc)
- checks = append(checks, check)
- } else {
- checks = append(checks, api.Check{
- Category: api.Authentication,
- Name: "DMARC Policy",
- Status: api.CheckStatusWarn,
- Score: 0.0,
- Message: "No DMARC authentication result found",
- Severity: api.PtrTo(api.CheckSeverityMedium),
- Advice: api.PtrTo("Implement DMARC policy for your domain"),
- })
- }
-
- // BIMI check (optional, informational only)
- if results.Bimi != nil {
- check := a.generateBIMICheck(results.Bimi)
- checks = append(checks, check)
- }
-
- // ARC check (optional, for forwarded emails)
- if results.Arc != nil {
- check := a.generateARCCheck(results.Arc)
- checks = append(checks, check)
- }
-
- return checks
-}
-
-func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
- check := api.Check{
- Category: api.Authentication,
- Name: "SPF Record",
- }
-
- switch spf.Result {
- case api.AuthResultResultPass:
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Message = "SPF validation passed"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your SPF record is properly configured")
- case api.AuthResultResultFail:
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Message = "SPF validation failed"
- check.Severity = api.PtrTo(api.CheckSeverityCritical)
- check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
- case api.AuthResultResultSoftfail:
- check.Status = api.CheckStatusWarn
- check.Score = 0.5
- check.Message = "SPF validation softfail"
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("Review your SPF record configuration")
- case api.AuthResultResultNeutral:
- check.Status = api.CheckStatusWarn
- check.Score = 0.5
- check.Message = "SPF validation neutral"
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Advice = api.PtrTo("Consider tightening your SPF policy")
- default:
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("Review your SPF record configuration")
- }
-
- if spf.Domain != nil {
- details := fmt.Sprintf("Domain: %s", *spf.Domain)
- check.Details = &details
- }
-
- return check
-}
-
-func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
- check := api.Check{
- Category: api.Authentication,
- Name: fmt.Sprintf("DKIM Signature #%d", index+1),
- }
-
- switch dkim.Result {
- case api.AuthResultResultPass:
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Message = "DKIM signature is valid"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your DKIM signature is properly configured")
- case api.AuthResultResultFail:
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Message = "DKIM signature validation failed"
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
- default:
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
- }
-
- var detailsParts []string
- if dkim.Domain != nil {
- detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
- }
- if dkim.Selector != nil {
- detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
- }
- if len(detailsParts) > 0 {
- details := strings.Join(detailsParts, ", ")
- check.Details = &details
- }
-
- return check
-}
-
-func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
- check := api.Check{
- Category: api.Authentication,
- Name: "DMARC Policy",
- }
-
- switch dmarc.Result {
- case api.AuthResultResultPass:
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Message = "DMARC validation passed"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
- case api.AuthResultResultFail:
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Message = "DMARC validation failed"
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
- default:
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("Configure DMARC policy for your domain")
- }
-
- if dmarc.Domain != nil {
- details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
- check.Details = &details
- }
-
- return check
-}
-
-func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check {
- check := api.Check{
- Category: api.Authentication,
- Name: "BIMI (Brand Indicators)",
- }
-
- switch bimi.Result {
- case api.AuthResultResultPass:
- check.Status = api.CheckStatusPass
- check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
- check.Message = "BIMI validation passed"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
- case api.AuthResultResultFail:
- check.Status = api.CheckStatusInfo
- check.Score = 0.0
- check.Message = "BIMI validation failed"
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
- default:
- check.Status = api.CheckStatusInfo
- check.Score = 0.0
- check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
- }
-
- if bimi.Domain != nil {
- details := fmt.Sprintf("Domain: %s", *bimi.Domain)
- check.Details = &details
- }
-
- return check
-}
-
-func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
- check := api.Check{
- Category: api.Authentication,
- Name: "ARC (Authenticated Received Chain)",
- }
-
- switch arc.Result {
- case api.ARCResultResultPass:
- check.Status = api.CheckStatusPass
- check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding)
- check.Message = "ARC chain validation passed"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
- case api.ARCResultResultFail:
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Message = "ARC chain validation failed"
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
- default:
- check.Status = api.CheckStatusInfo
- check.Score = 0.0
- check.Message = "No ARC chain present"
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
- }
-
- // Build details
- var detailsParts []string
- if arc.ChainLength != nil {
- detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
- }
- if arc.ChainValid != nil {
- detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
- }
- if arc.Details != nil {
- detailsParts = append(detailsParts, *arc.Details)
- }
-
- if len(detailsParts) > 0 {
- details := strings.Join(detailsParts, ", ")
- check.Details = &details
- }
-
- return check
-}
diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go
new file mode 100644
index 0000000..4165d8b
--- /dev/null
+++ b/pkg/analyzer/authentication_dkim.go
@@ -0,0 +1,87 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// parseDKIMResult parses DKIM result from Authentication-Results
+// Example: dkim=pass header.d=example.com header.s=selector1
+func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
+ result := &model.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 = model.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 = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
+
+ return result
+}
+
+func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.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 == model.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..0576854
--- /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/model"
+)
+
+func TestParseDKIMResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.AuthResultResult
+ expectedDomain string
+ expectedSelector string
+ }{
+ {
+ name: "DKIM pass with domain and selector",
+ part: "dkim=pass header.d=example.com header.s=default",
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: "example.com",
+ expectedSelector: "default",
+ },
+ {
+ name: "DKIM fail",
+ part: "dkim=fail header.d=example.com header.s=selector1",
+ expectedResult: model.AuthResultResultFail,
+ expectedDomain: "example.com",
+ expectedSelector: "selector1",
+ },
+ {
+ name: "DKIM with short form (d= and s=)",
+ part: "dkim=pass d=example.com s=default",
+ expectedResult: model.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..c89093d
--- /dev/null
+++ b/pkg/analyzer/authentication_dmarc.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 (
+ "regexp"
+ "strings"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// parseDMARCResult parses DMARC result from Authentication-Results
+// Example: dmarc=pass action=none header.from=example.com
+func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult {
+ result := &model.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 = model.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 = utils.PtrTo(strings.TrimPrefix(part, "dmarc="))
+
+ return result
+}
+
+func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) {
+ if results.Dmarc != nil {
+ switch results.Dmarc.Result {
+ case model.AuthResultResultPass:
+ return 100
+ case model.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..69779a7
--- /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/model"
+)
+
+func TestParseDMARCResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.AuthResultResult
+ expectedDomain string
+ }{
+ {
+ name: "DMARC pass",
+ part: "dmarc=pass action=none header.from=example.com",
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "DMARC fail",
+ part: "dmarc=fail action=quarantine header.from=example.com",
+ expectedResult: model.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..3ed045c
--- /dev/null
+++ b/pkg/analyzer/authentication_iprev.go
@@ -0,0 +1,74 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// 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) *model.IPRevResult {
+ result := &model.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 = model.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 = utils.PtrTo(strings.TrimPrefix(part, "iprev="))
+
+ return result
+}
+
+func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) {
+ if results.Iprev != nil {
+ switch results.Iprev.Result {
+ case model.Pass:
+ return 100
+ default: // fail, temperror, permerror
+ return 0
+ }
+ }
+
+ return 100
+}
diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go
new file mode 100644
index 0000000..55f85d5
--- /dev/null
+++ b/pkg/analyzer/authentication_iprev_test.go
@@ -0,0 +1,226 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+func TestParseIPRevResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.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: model.Pass,
+ expectedIP: utils.PtrTo("195.110.101.58"),
+ expectedHostname: utils.PtrTo("authsmtp74.register.it"),
+ },
+ {
+ name: "IPRev pass without smtp prefix",
+ part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("192.0.2.1"),
+ expectedHostname: utils.PtrTo("mail.example.com"),
+ },
+ {
+ name: "IPRev fail",
+ part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
+ expectedResult: model.Fail,
+ expectedIP: utils.PtrTo("198.51.100.42"),
+ expectedHostname: utils.PtrTo("unknown.host.com"),
+ },
+ {
+ name: "IPRev temperror",
+ part: "iprev=temperror smtp.remote-ip=203.0.113.1",
+ expectedResult: model.Temperror,
+ expectedIP: utils.PtrTo("203.0.113.1"),
+ expectedHostname: nil,
+ },
+ {
+ name: "IPRev permerror",
+ part: "iprev=permerror smtp.remote-ip=192.0.2.100",
+ expectedResult: model.Permerror,
+ expectedIP: utils.PtrTo("192.0.2.100"),
+ expectedHostname: nil,
+ },
+ {
+ name: "IPRev with IPv6",
+ part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("2001:db8::1"),
+ expectedHostname: utils.PtrTo("ipv6.example.com"),
+ },
+ {
+ name: "IPRev with subdomain hostname",
+ part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("192.0.2.50"),
+ expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
+ },
+ {
+ name: "IPRev pass without parentheses",
+ part: "iprev=pass smtp.remote-ip=192.0.2.200",
+ expectedResult: model.Pass,
+ expectedIP: utils.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 *model.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: utils.PtrTo(model.Pass),
+ expectedIP: utils.PtrTo("195.110.101.58"),
+ expectedHostname: utils.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: utils.PtrTo(model.Pass),
+ expectedIP: utils.PtrTo("192.0.2.1"),
+ expectedHostname: utils.PtrTo("mail.example.com"),
+ },
+ {
+ name: "IPRev fail",
+ header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
+ expectedIPRevResult: utils.PtrTo(model.Fail),
+ expectedIP: utils.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: utils.PtrTo(model.Pass),
+ expectedIP: utils.PtrTo("192.0.2.1"),
+ expectedHostname: utils.PtrTo("first.com"),
+ },
+ }
+
+ analyzer := NewAuthenticationAnalyzer("")
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ results := &model.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..1488c98
--- /dev/null
+++ b/pkg/analyzer/authentication_spf.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 (
+ "regexp"
+ "strings"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// parseSPFResult parses SPF result from Authentication-Results
+// Example: spf=pass smtp.mailfrom=sender@example.com
+func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
+ result := &model.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 = model.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 = utils.PtrTo(strings.TrimPrefix(part, "spf="))
+
+ return result
+}
+
+// parseLegacySPF attempts to parse SPF from Received-SPF header
+func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult {
+ receivedSPF := email.Header.Get("Received-SPF")
+ if receivedSPF == "" {
+ return nil
+ }
+
+ // Verify receiver matches our hostname
+ if a.receiverHostname != "" {
+ receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`)
+ if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
+ if matches[1] != a.receiverHostname {
+ return nil
+ }
+ }
+ }
+
+ result := &model.AuthResult{}
+
+ // Extract result (first word)
+ parts := strings.Fields(receivedSPF)
+ if len(parts) > 0 {
+ resultStr := strings.ToLower(parts[0])
+ result.Result = model.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 *model.AuthenticationResults) (score int) {
+ if results.Spf != nil {
+ switch results.Spf.Result {
+ case model.AuthResultResultPass:
+ return 100
+ case model.AuthResultResultNeutral, model.AuthResultResultNone:
+ return 50
+ case model.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..210505a
--- /dev/null
+++ b/pkg/analyzer/authentication_spf_test.go
@@ -0,0 +1,213 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+func TestParseSPFResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.AuthResultResult
+ expectedDomain string
+ }{
+ {
+ name: "SPF pass with domain",
+ part: "spf=pass smtp.mailfrom=sender@example.com",
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "SPF fail",
+ part: "spf=fail smtp.mailfrom=sender@example.com",
+ expectedResult: model.AuthResultResultFail,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "SPF neutral",
+ part: "spf=neutral smtp.mailfrom=sender@example.com",
+ expectedResult: model.AuthResultResultNeutral,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "SPF softfail",
+ part: "spf=softfail smtp.mailfrom=sender@example.com",
+ expectedResult: model.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 model.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: model.AuthResultResultPass,
+ expectedDomain: utils.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: model.AuthResultResultFail,
+ expectedDomain: utils.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: model.AuthResultResultSoftfail,
+ expectedDomain: utils.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: model.AuthResultResultNeutral,
+ expectedDomain: utils.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: model.AuthResultResultNone,
+ expectedDomain: utils.PtrTo("company.io"),
+ },
+ {
+ name: "SPF temperror",
+ receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
+ expectedResult: model.AuthResultResultTemperror,
+ expectedDomain: utils.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: model.AuthResultResultPermerror,
+ expectedDomain: utils.PtrTo("invalid.test"),
+ },
+ {
+ name: "SPF pass without domain extraction",
+ receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
+ expectedResult: model.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: model.AuthResultResultPass,
+ expectedDomain: utils.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
index 17ac24e..0b17bf0 100644
--- a/pkg/analyzer/authentication_test.go
+++ b/pkg/analyzer/authentication_test.go
@@ -22,552 +22,90 @@
package analyzer
import (
- "strings"
"testing"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
-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 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)
- }
- })
- }
-}
-
-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)
- }
- })
- }
-}
-
-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)
- }
- }
- })
- }
-}
-
-func TestGenerateAuthSPFCheck(t *testing.T) {
- tests := []struct {
- name string
- spf *api.AuthResult
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "SPF pass",
- spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "SPF fail",
- spf: &api.AuthResult{
- Result: api.AuthResultResultFail,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- {
- name: "SPF softfail",
- spf: &api.AuthResult{
- Result: api.AuthResultResultSoftfail,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusWarn,
- expectedScore: 0.5,
- },
- {
- name: "SPF neutral",
- spf: &api.AuthResult{
- Result: api.AuthResultResultNeutral,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusWarn,
- expectedScore: 0.5,
- },
- }
-
- analyzer := NewAuthenticationAnalyzer()
-
- 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.Authentication {
- t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
- }
- if check.Name != "SPF Record" {
- t.Errorf("Name = %q, want %q", check.Name, "SPF Record")
- }
- })
- }
-}
-
-func TestGenerateAuthDKIMCheck(t *testing.T) {
- tests := []struct {
- name string
- dkim *api.AuthResult
- index int
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "DKIM pass",
- dkim: &api.AuthResult{
- Result: api.AuthResultResultPass,
- Domain: api.PtrTo("example.com"),
- Selector: api.PtrTo("default"),
- },
- index: 0,
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "DKIM fail",
- dkim: &api.AuthResult{
- Result: api.AuthResultResultFail,
- Domain: api.PtrTo("example.com"),
- Selector: api.PtrTo("default"),
- },
- index: 0,
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- {
- name: "DKIM none",
- dkim: &api.AuthResult{
- Result: api.AuthResultResultNone,
- Domain: api.PtrTo("example.com"),
- Selector: api.PtrTo("default"),
- },
- index: 0,
- expectedStatus: api.CheckStatusWarn,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewAuthenticationAnalyzer()
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateDKIMCheck(tt.dkim, tt.index)
-
- 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.Authentication {
- t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
- }
- if !strings.Contains(check.Name, "DKIM Signature") {
- t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name)
- }
- })
- }
-}
-
-func TestGenerateAuthDMARCCheck(t *testing.T) {
- tests := []struct {
- name string
- dmarc *api.AuthResult
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "DMARC pass",
- dmarc: &api.AuthResult{
- Result: api.AuthResultResultPass,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "DMARC fail",
- dmarc: &api.AuthResult{
- Result: api.AuthResultResultFail,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewAuthenticationAnalyzer()
-
- 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.Authentication {
- t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
- }
- if check.Name != "DMARC Policy" {
- t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy")
- }
- })
- }
-}
-
-func TestGenerateAuthBIMICheck(t *testing.T) {
- tests := []struct {
- name string
- bimi *api.AuthResult
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "BIMI pass",
- bimi: &api.AuthResult{
- Result: api.AuthResultResultPass,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 0.0, // BIMI doesn't contribute to score
- },
- {
- name: "BIMI fail",
- bimi: &api.AuthResult{
- Result: api.AuthResultResultFail,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusInfo,
- expectedScore: 0.0,
- },
- {
- name: "BIMI none",
- bimi: &api.AuthResult{
- Result: api.AuthResultResultNone,
- Domain: api.PtrTo("example.com"),
- },
- expectedStatus: api.CheckStatusInfo,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewAuthenticationAnalyzer()
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateBIMICheck(tt.bimi)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Score != tt.expectedScore {
- t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
- }
- if check.Category != api.Authentication {
- t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
- }
- if check.Name != "BIMI (Brand Indicators)" {
- t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)")
- }
-
- // BIMI should always have score of 0.0 (branding feature)
- if check.Score != 0.0 {
- t.Error("BIMI should not contribute to deliverability score")
- }
- })
- }
-}
-
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
- results *api.AuthenticationResults
- expectedScore float32
+ results *model.AuthenticationResults
+ expectedScore int
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
+ Dkim: &[]model.AuthResult{
+ {Result: model.AuthResultResultPass},
},
- Dmarc: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ Dmarc: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
},
- expectedScore: 3.0,
+ expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30
},
{
name: "SPF and DKIM only",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
+ Dkim: &[]model.AuthResult{
+ {Result: model.AuthResultResultPass},
},
},
- expectedScore: 2.0,
+ expectedScore: 60, // SPF=30 + DKIM=30
},
{
name: "SPF fail, DKIM pass",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultFail,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultFail,
},
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
+ Dkim: &[]model.AuthResult{
+ {Result: model.AuthResultResultPass},
},
},
- expectedScore: 1.0,
+ expectedScore: 30, // SPF=0 + DKIM=30
},
{
name: "SPF softfail",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultSoftfail,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultSoftfail,
},
},
- expectedScore: 0.5,
+ expectedScore: 5, // 30 * 17 / 100 = 5
},
{
name: "No authentication",
- results: &api.AuthenticationResults{},
- expectedScore: 0.0,
+ results: &model.AuthenticationResults{},
+ expectedScore: 0,
},
{
- name: "BIMI doesn't affect score",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ name: "BIMI adds to score",
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
- Bimi: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ Bimi: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
},
- expectedScore: 1.0, // Only SPF counted, not BIMI
+ expectedScore: 40, // SPF (30) + BIMI (10)
},
}
- scorer := NewDeliverabilityScorer()
+ scorer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- score := scorer.GetAuthenticationScore(tt.results)
+ score, _ := scorer.CalculateAuthenticationScore(tt.results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
@@ -576,271 +114,326 @@ func TestGetAuthenticationScore(t *testing.T) {
}
}
-func TestGenerateAuthenticationChecks(t *testing.T) {
+func TestParseAuthenticationResultsHeader(t *testing.T) {
tests := []struct {
- name string
- results *api.AuthenticationResults
- expectedChecks int
+ name string
+ header string
+ expectedSPFResult *model.AuthResultResult
+ expectedSPFDomain *string
+ expectedDKIMCount int
+ expectedDKIMResult *model.AuthResultResult
+ expectedDMARCResult *model.AuthResultResult
+ expectedDMARCDomain *string
+ expectedBIMIResult *model.AuthResultResult
+ expectedARCResult *model.ARCResultResult
}{
{
- name: "All authentication methods present",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
- },
- Dmarc: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- Bimi: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- },
- expectedChecks: 4, // SPF, DKIM, DMARC, BIMI
+ 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: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
+ expectedDKIMCount: 1,
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCDomain: utils.PtrTo("example.com"),
},
{
- name: "Without BIMI",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
- },
- Dmarc: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- },
- expectedChecks: 3, // SPF, DKIM, DMARC
+ name: "SPF only",
+ header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("domain.com"),
+ expectedDKIMCount: 0,
+ expectedDMARCResult: nil,
},
{
- name: "No authentication results",
- results: &api.AuthenticationResults{},
- expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing
+ name: "DKIM only",
+ header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
+ expectedSPFResult: nil,
+ expectedDKIMCount: 1,
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
},
{
- name: "With ARC",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
- },
- Dmarc: &api.AuthResult{
- Result: api.AuthResultResultPass,
- },
- Arc: &api.ARCResult{
- Result: api.ARCResultResultPass,
- ChainLength: api.PtrTo(2),
- ChainValid: api.PtrTo(true),
- },
- },
- expectedChecks: 4, // SPF, DKIM, DMARC, ARC
+ 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: utils.PtrTo(model.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: utils.PtrTo(model.AuthResultResultFail),
+ expectedSPFDomain: utils.PtrTo("example.com"),
+ expectedDKIMCount: 1,
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCResult: nil,
+ },
+ {
+ name: "SPF softfail",
+ header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail),
+ expectedSPFDomain: utils.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: utils.PtrTo(model.AuthResultResultPass),
+ expectedDKIMCount: 1,
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail),
+ expectedDMARCDomain: utils.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: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
+ expectedDKIMCount: 0,
+ expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
+ },
+ {
+ name: "ARC pass",
+ header: "mail.example.com; arc=pass",
+ expectedSPFResult: nil,
+ expectedDKIMCount: 0,
+ expectedARCResult: utils.PtrTo(model.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: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
+ expectedDKIMCount: 1,
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCDomain: utils.PtrTo("example.com"),
+ expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedARCResult: utils.PtrTo(model.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: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.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: utils.PtrTo(model.AuthResultResultPass),
+ },
+ {
+ name: "SPF neutral",
+ header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral),
+ expectedSPFDomain: utils.PtrTo("example.com"),
+ expectedDKIMCount: 0,
+ },
+ {
+ name: "SPF none",
+ header: "mail.example.com; spf=none",
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultNone),
+ expectedDKIMCount: 0,
},
}
- analyzer := NewAuthenticationAnalyzer()
+ analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- checks := analyzer.GenerateAuthenticationChecks(tt.results)
+ results := &model.AuthenticationResults{}
+ analyzer.parseAuthenticationResultsHeader(tt.header, results)
- if len(checks) != tt.expectedChecks {
- t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks)
+ // 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)
+ }
}
- // Verify all checks have the Authentication category
- for _, check := range checks {
- if check.Category != api.Authentication {
- t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication)
+ // 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 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,
- },
- }
+func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
+ // This test verifies that only the first occurrence of each auth method is parsed
+ analyzer := NewAuthenticationAnalyzer("")
- analyzer := NewAuthenticationAnalyzer()
+ t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
+ header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
+ results := &model.AuthenticationResults{}
+ analyzer.parseAuthenticationResultsHeader(header, results)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.parseARCResult(tt.part)
+ if results.Spf == nil {
+ t.Fatal("Expected SPF result, got nil")
+ }
+ if results.Spf.Result != model.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")
+ }
+ })
- 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)
- }
- })
- }
-}
-
-func TestGenerateARCCheck(t *testing.T) {
- tests := []struct {
- name string
- arc *api.ARCResult
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "ARC pass",
- arc: &api.ARCResult{
- Result: api.ARCResultResultPass,
- ChainLength: api.PtrTo(2),
- ChainValid: api.PtrTo(true),
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 0.0, // ARC doesn't contribute to score
- },
- {
- name: "ARC fail",
- arc: &api.ARCResult{
- Result: api.ARCResultResultFail,
- ChainLength: api.PtrTo(1),
- ChainValid: api.PtrTo(false),
- },
- expectedStatus: api.CheckStatusWarn,
- expectedScore: 0.0,
- },
- {
- name: "ARC none",
- arc: &api.ARCResult{
- Result: api.ARCResultResultNone,
- ChainLength: api.PtrTo(0),
- ChainValid: api.PtrTo(true),
- },
- expectedStatus: api.CheckStatusInfo,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewAuthenticationAnalyzer()
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateARCCheck(tt.arc)
-
- 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.Authentication {
- t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
- }
- if !strings.Contains(check.Name, "ARC") {
- t.Errorf("Name should contain 'ARC', got %q", check.Name)
- }
- })
- }
+ 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 := &model.AuthenticationResults{}
+ analyzer.parseAuthenticationResultsHeader(header, results)
+
+ if results.Dmarc == nil {
+ t.Fatal("Expected DMARC result, got nil")
+ }
+ if results.Dmarc.Result != model.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 := &model.AuthenticationResults{}
+ analyzer.parseAuthenticationResultsHeader(header, results)
+
+ if results.Arc == nil {
+ t.Fatal("Expected ARC result, got nil")
+ }
+ if results.Arc.Result != model.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 := &model.AuthenticationResults{}
+ analyzer.parseAuthenticationResultsHeader(header, results)
+
+ if results.Bimi == nil {
+ t.Fatal("Expected BIMI result, got nil")
+ }
+ if results.Bimi.Result != model.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 := &model.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 != model.AuthResultResultPass {
+ t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
+ }
+ if (*results.Dkim)[1].Result != model.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..45c2e2e
--- /dev/null
+++ b/pkg/analyzer/authentication_x_aligned_from.go
@@ -0,0 +1,66 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
+// Example: x-aligned-from=pass (Address match)
+func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult {
+ result := &model.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 = model.AuthResultResult(resultStr)
+ }
+
+ // Extract details (everything after the result)
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
+
+ return result
+}
+
+func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) {
+ if results.XAlignedFrom != nil {
+ switch results.XAlignedFrom.Result {
+ case model.AuthResultResultPass:
+ // pass: no impact
+ return 0
+ case model.AuthResultResultFail:
+ // fail: negative contribution
+ return -100
+ 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..ee90c0d
--- /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/model"
+)
+
+func TestParseXAlignedFromResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.AuthResultResult
+ expectedDetail string
+ }{
+ {
+ name: "x-aligned-from pass with details",
+ part: "x-aligned-from=pass (Address match)",
+ expectedResult: model.AuthResultResultPass,
+ expectedDetail: "pass (Address match)",
+ },
+ {
+ name: "x-aligned-from fail with reason",
+ part: "x-aligned-from=fail (Address mismatch)",
+ expectedResult: model.AuthResultResultFail,
+ expectedDetail: "fail (Address mismatch)",
+ },
+ {
+ name: "x-aligned-from pass minimal",
+ part: "x-aligned-from=pass",
+ expectedResult: model.AuthResultResultPass,
+ expectedDetail: "pass",
+ },
+ {
+ name: "x-aligned-from neutral",
+ part: "x-aligned-from=neutral (No alignment check performed)",
+ expectedResult: model.AuthResultResultNeutral,
+ expectedDetail: "neutral (No alignment check performed)",
+ },
+ {
+ name: "x-aligned-from none",
+ part: "x-aligned-from=none",
+ expectedResult: model.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 *model.AuthResult
+ expectedScore int
+ }{
+ {
+ name: "pass result gives no penalty",
+ result: &model.AuthResult{
+ Result: model.AuthResultResultPass,
+ },
+ expectedScore: 0,
+ },
+ {
+ name: "fail result gives full penalty",
+ result: &model.AuthResult{
+ Result: model.AuthResultResultFail,
+ },
+ expectedScore: -100,
+ },
+ {
+ name: "neutral result gives zero score",
+ result: &model.AuthResult{
+ Result: model.AuthResultResultNeutral,
+ },
+ expectedScore: 0,
+ },
+ {
+ name: "none result gives zero score",
+ result: &model.AuthResult{
+ Result: model.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 := &model.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..b33279e
--- /dev/null
+++ b/pkg/analyzer/authentication_x_google_dkim.go
@@ -0,0 +1,74 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// 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) *model.AuthResult {
+ result := &model.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 = model.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 = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
+
+ return result
+}
+
+func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
+ if results.XGoogleDkim != nil {
+ switch results.XGoogleDkim.Result {
+ case model.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..4013340
--- /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/model"
+)
+
+func TestParseXGoogleDKIMResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult model.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: model.AuthResultResultPass,
+ expectedDomain: "1e100.net",
+ },
+ {
+ name: "x-google-dkim pass with short form",
+ part: "x-google-dkim=pass d=gmail.com",
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: "gmail.com",
+ },
+ {
+ name: "x-google-dkim fail",
+ part: "x-google-dkim=fail header.d=example.com",
+ expectedResult: model.AuthResultResultFail,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "x-google-dkim with minimal info",
+ part: "x-google-dkim=pass",
+ expectedResult: model.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
index ac46259..06f8ddf 100644
--- a/pkg/analyzer/content.go
+++ b/pkg/analyzer/content.go
@@ -27,18 +27,22 @@ import (
"net/http"
"net/url"
"regexp"
+ "slices"
"strings"
"time"
"unicode"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
"golang.org/x/net/html"
)
// ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct {
- Timeout time.Duration
- httpClient *http.Client
+ Timeout time.Duration
+ httpClient *http.Client
+ listUnsubscribeURLs []string // URLs from List-Unsubscribe header
+ hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
}
// NewContentAnalyzer creates a new content analyzer with configurable timeout
@@ -63,6 +67,7 @@ func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
// ContentResults represents content analysis results
type ContentResults struct {
+ IsMultipart bool
HTMLValid bool
HTMLErrors []string
Links []LinkCheck
@@ -75,6 +80,12 @@ type ContentResults struct {
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
@@ -101,6 +112,15 @@ type ImageCheck struct {
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()
@@ -117,16 +137,57 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
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
@@ -170,6 +231,18 @@ func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
// 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
@@ -195,6 +268,59 @@ func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
}
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
index 9a6d26f..6bc7c39 100644
--- a/pkg/analyzer/dns.go
+++ b/pkg/analyzer/dns.go
@@ -22,698 +22,215 @@
package analyzer
import (
- "context"
- "fmt"
- "net"
- "regexp"
- "strings"
"time"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
- resolver *net.Resolver
+ 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
}
- return &DNSAnalyzer{
- Timeout: timeout,
- resolver: &net.Resolver{
- PreferGo: true,
- },
+ if resolver == nil {
+ resolver = NewStandardDNSResolver()
+ }
+ return &DNSAnalyzer{
+ Timeout: timeout,
+ resolver: resolver,
}
-}
-
-// DNSResults represents DNS validation results for an email
-type DNSResults struct {
- Domain string
- MXRecords []MXRecord
- SPFRecord *SPFRecord
- DKIMRecords []DKIMRecord
- DMARCRecord *DMARCRecord
- BIMIRecord *BIMIRecord
- Errors []string
-}
-
-// MXRecord represents an MX record
-type MXRecord struct {
- Host string
- Priority uint16
- Valid bool
- Error string
-}
-
-// SPFRecord represents an SPF record
-type SPFRecord struct {
- Record string
- Valid bool
- Error string
-}
-
-// DKIMRecord represents a DKIM record
-type DKIMRecord struct {
- Selector string
- Domain string
- Record string
- Valid bool
- Error string
-}
-
-// DMARCRecord represents a DMARC record
-type DMARCRecord struct {
- Record string
- Policy string // none, quarantine, reject
- Valid bool
- Error string
-}
-
-// BIMIRecord represents a BIMI record
-type BIMIRecord struct {
- Selector string
- Domain string
- Record string
- LogoURL string // URL to the brand logo (SVG)
- VMCURL string // URL to Verified Mark Certificate (optional)
- Valid bool
- Error string
}
// AnalyzeDNS performs DNS validation for the email's domain
-func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
+func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.DNSResults {
// Extract domain from From address
- domain := d.extractDomain(email)
- if domain == "" {
- return &DNSResults{
- Errors: []string{"Unable to extract domain from email"},
+ if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
+ return &model.DNSResults{
+ Errors: &[]string{"Unable to extract domain from email"},
}
}
+ fromDomain := *headersResults.DomainAlignment.FromDomain
- results := &DNSResults{
- Domain: domain,
+ results := &model.DNSResults{
+ FromDomain: fromDomain,
+ RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
}
- // Check MX records
- results.MXRecords = d.checkMXRecords(domain)
+ // 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
+ }
- // 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)
- }
+ // 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 by parsing DKIM-Signature headers directly
+ for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
+ dkimRecord := d.checkDKIMRecord(sig)
+ if dkimRecord != nil {
+ if results.DkimRecords == nil {
+ results.DkimRecords = new([]model.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) *model.DNSResults {
+ results := &model.DNSResults{
+ FromDomain: domain,
+ }
+
+ // Check MX records
+ results.FromMxRecords = d.checkMXRecords(domain)
+
+ // Check SPF records
+ results.SpfRecords = d.checkSPFRecords(domain)
+
// Check DMARC record
- results.DMARCRecord = d.checkDMARCRecord(domain)
+ results.DmarcRecord = d.checkDMARCRecord(domain)
- // Check BIMI record (using default selector)
- results.BIMIRecord = d.checkBIMIRecord(domain, "default")
+ // Check BIMI record with default selector
+ results.BimiRecord = d.checkBIMIRecord(domain, "default")
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
-}
-
-// checkBIMIRecord looks up and validates BIMI record for a domain and selector
-func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
- // BIMI records are at: selector._bimi.domain
- bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
-
- ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
- defer cancel()
-
- txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
- if err != nil {
- return &BIMIRecord{
- Selector: selector,
- Domain: domain,
- Valid: false,
- Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
- }
- }
-
- if len(txtRecords) == 0 {
- return &BIMIRecord{
- Selector: selector,
- Domain: domain,
- Valid: false,
- Error: "No BIMI record found",
- }
- }
-
- // Concatenate all TXT record parts (BIMI can be split)
- bimiRecord := strings.Join(txtRecords, "")
-
- // Extract logo URL and VMC URL
- logoURL := d.extractBIMITag(bimiRecord, "l")
- vmcURL := d.extractBIMITag(bimiRecord, "a")
-
- // Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
- if !d.validateBIMI(bimiRecord) {
- return &BIMIRecord{
- Selector: selector,
- Domain: domain,
- Record: bimiRecord,
- LogoURL: logoURL,
- VMCURL: vmcURL,
- Valid: false,
- Error: "BIMI record appears malformed",
- }
- }
-
- return &BIMIRecord{
- Selector: selector,
- Domain: domain,
- Record: bimiRecord,
- LogoURL: logoURL,
- VMCURL: vmcURL,
- Valid: true,
- }
-}
-
-// extractBIMITag extracts a tag value from a BIMI record
-func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
- // Look for tag=value pattern
- re := regexp.MustCompile(tag + `=([^;]+)`)
- matches := re.FindStringSubmatch(record)
- if len(matches) > 1 {
- return strings.TrimSpace(matches[1])
- }
- return ""
-}
-
-// validateBIMI performs basic BIMI record validation
-func (d *DNSAnalyzer) validateBIMI(record string) bool {
- // Must start with v=BIMI1
- if !strings.HasPrefix(record, "v=BIMI1") {
- return false
- }
-
- // Must have a logo URL tag (l=)
- if !strings.Contains(record, "l=") {
- return false
- }
-
- return true
-}
-
-// GenerateDNSChecks generates check results for DNS validation
-func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
- var checks []api.Check
-
+// 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 *model.DNSResults) (int, string) {
if results == nil {
- return checks
+ return 0, ""
}
- // MX record check
- checks = append(checks, d.generateMXCheck(results))
+ score := 0
- // SPF record check
- if results.SPFRecord != nil {
- checks = append(checks, d.generateSPFCheck(results.SPFRecord))
+ // 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+"
+ }
}
- // DKIM record checks
- for _, dkim := range results.DKIMRecords {
- checks = append(checks, d.generateDKIMCheck(&dkim))
+ // Ensure score doesn't exceed maximum
+ if score > 100 {
+ score = 100
}
- // DMARC record check
- if results.DMARCRecord != nil {
- checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
+ // Ensure score is non-negative
+ if score < 0 {
+ score = 0
}
- // BIMI record check (optional)
- if results.BIMIRecord != nil {
- checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
- }
-
- return checks
+ return score, ScoreToGradeKind(score)
}
-// generateMXCheck creates a check for MX records
-func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
- check := api.Check{
- Category: api.Dns,
- Name: "MX Records",
+// 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 *model.DNSResults, senderIP string) (int, string) {
+ if results == nil {
+ return 0, ""
}
- if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Severity = api.PtrTo(api.CheckSeverityCritical)
+ score := 0
- if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
- check.Message = results.MXRecords[0].Error
- } else {
- check.Message = "No valid MX records found"
- }
- check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
+ // PTR and Forward DNS: 20 points
+ score += 20 * d.calculatePTRScore(results, senderIP) / 100
- // 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))
+ // 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+"
}
- details := strings.Join(mxList, ", ")
- check.Details = &details
- check.Advice = api.PtrTo("Your MX records are properly configured")
}
- return check
-}
-
-// generateSPFCheck creates a check for SPF records
-func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
- check := api.Check{
- Category: api.Dns,
- Name: "SPF Record",
- }
-
- if !spf.Valid {
- // If no record exists at all, it's a failure
- if spf.Record == "" {
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Message = spf.Error
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
- } else {
- // If record exists but is invalid, it's a warning
- check.Status = api.CheckStatusWarn
- check.Score = 0.5
- check.Message = "SPF record found but appears invalid"
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("Review and fix your SPF record syntax")
- check.Details = &spf.Record
- }
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Message = "Valid SPF record found"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Details = &spf.Record
- check.Advice = api.PtrTo("Your SPF record is properly configured")
- }
-
- return check
-}
-
-// generateDKIMCheck creates a check for DKIM records
-func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
- check := api.Check{
- Category: api.Dns,
- Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
- }
-
- if !dkim.Valid {
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
- details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
- check.Details = &details
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Message = "Valid DKIM record found"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
- check.Details = &details
- check.Advice = api.PtrTo("Your DKIM record is properly published")
- }
-
- return check
-}
-
-// generateDMARCCheck creates a check for DMARC records
-func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
- check := api.Check{
- Category: api.Dns,
- Name: "DMARC Record",
- }
-
- if !dmarc.Valid {
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Message = dmarc.Error
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Details = &dmarc.Record
-
- // Provide advice based on policy
- switch dmarc.Policy {
- case "none":
- advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
- check.Advice = &advice
- case "quarantine":
- advice := "DMARC policy is set to 'quarantine'. This provides good protection"
- check.Advice = &advice
- case "reject":
- advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
- check.Advice = &advice
- default:
- advice := "Your DMARC record is properly configured"
- check.Advice = &advice
- }
- }
-
- return check
-}
-
-// generateBIMICheck creates a check for BIMI records
-func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
- check := api.Check{
- Category: api.Dns,
- Name: "BIMI Record",
- }
-
- if !bimi.Valid {
- // BIMI is optional, so missing record is just informational
- if bimi.Record == "" {
- check.Status = api.CheckStatusInfo
- check.Score = 0.0
- check.Message = "No BIMI record found (optional)"
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
- } else {
- // If record exists but is invalid
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
- check.Details = &bimi.Record
- }
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
- check.Message = "Valid BIMI record found"
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
-
- // Build details with logo and VMC URLs
- var detailsParts []string
- detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
- if bimi.LogoURL != "" {
- detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
- }
- if bimi.VMCURL != "" {
- detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
- check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
- } else {
- check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
- }
-
- details := strings.Join(detailsParts, ", ")
- check.Details = &details
- }
-
- return check
+ // 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..223bfdc
--- /dev/null
+++ b/pkg/analyzer/dns_bimi.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"
+ "regexp"
+ "strings"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// checkBIMIRecord looks up and validates BIMI record for a domain and selector
+func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.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 &model.BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Valid: false,
+ Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
+ }
+ }
+
+ if len(txtRecords) == 0 {
+ return &model.BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Valid: false,
+ Error: utils.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 &model.BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Record: &bimiRecord,
+ LogoUrl: &logoURL,
+ VmcUrl: &vmcURL,
+ Valid: false,
+ Error: utils.PtrTo("BIMI record appears malformed"),
+ }
+ }
+
+ return &model.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..115e347
--- /dev/null
+++ b/pkg/analyzer/dns_dkim.go
@@ -0,0 +1,260 @@
+// 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"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header.
+type DKIMHeader struct {
+ Domain string
+ Selector string
+ Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256)
+}
+
+// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values.
+func parseDKIMSignatures(signatures []string) []DKIMHeader {
+ var results []DKIMHeader
+ for _, sig := range signatures {
+ var domain, selector, algorithm string
+ for _, part := range strings.Split(sig, ";") {
+ kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
+ if len(kv) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(kv[0])
+ val := strings.TrimSpace(kv[1])
+ switch key {
+ case "d":
+ domain = val
+ case "s":
+ selector = val
+ case "a":
+ algorithm = val
+ }
+ }
+ if domain != "" && selector != "" {
+ results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
+ }
+ }
+ return results
+}
+
+// parseDKIMTags splits a DKIM DNS record into a tag→value map.
+func parseDKIMTags(record string) map[string]string {
+ tags := make(map[string]string)
+ for _, part := range strings.Split(record, ";") {
+ kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
+ if len(kv) != 2 {
+ continue
+ }
+ tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
+ }
+ return tags
+}
+
+// parseKeySize derives the public key bit length from a base64-encoded DER public key.
+// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256.
+func parseKeySize(keyType, p string) *int {
+ switch strings.ToLower(keyType) {
+ case "ed25519":
+ return utils.PtrTo(256)
+ case "rsa", "":
+ der, err := base64.StdEncoding.DecodeString(p)
+ if err != nil {
+ // Try without padding
+ der, err = base64.RawStdEncoding.DecodeString(p)
+ if err != nil {
+ return nil
+ }
+ }
+ pub, err := x509.ParsePKIXPublicKey(der)
+ if err != nil {
+ return nil
+ }
+ if rsaPub, ok := pub.(interface{ Size() int }); ok {
+ bits := rsaPub.Size() * 8
+ return &bits
+ }
+ return nil
+ }
+ return nil
+}
+
+// checkDKIMRecord looks up and validates DKIM record for a domain and selector.
+func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
+ dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain)
+
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
+ if err != nil {
+ return &model.DKIMRecord{
+ Selector: h.Selector,
+ Domain: h.Domain,
+ SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
+ Valid: false,
+ Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
+ }
+ }
+
+ if len(txtRecords) == 0 {
+ return &model.DKIMRecord{
+ Selector: h.Selector,
+ Domain: h.Domain,
+ SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
+ Valid: false,
+ Error: utils.PtrTo("No DKIM record found"),
+ }
+ }
+
+ // Concatenate all TXT record parts (DKIM can be split)
+ dkimRecord := strings.Join(txtRecords, "")
+
+ if !d.validateDKIM(dkimRecord) {
+ return &model.DKIMRecord{
+ Selector: h.Selector,
+ Domain: h.Domain,
+ Record: utils.PtrTo(dkimRecord),
+ SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
+ Valid: false,
+ Error: utils.PtrTo("DKIM record appears malformed"),
+ }
+ }
+
+ tags := parseDKIMTags(dkimRecord)
+
+ keyType := tags["k"]
+ if keyType == "" {
+ keyType = "rsa" // RFC 6376 default
+ }
+
+ var hashAlgorithms []string
+ if h, ok := tags["h"]; ok && h != "" {
+ for _, alg := range strings.Split(h, ":") {
+ if a := strings.TrimSpace(alg); a != "" {
+ hashAlgorithms = append(hashAlgorithms, a)
+ }
+ }
+ }
+ if hashAlgorithms == nil {
+ hashAlgorithms = []string{}
+ }
+
+ return &model.DKIMRecord{
+ Selector: h.Selector,
+ Domain: h.Domain,
+ Record: &dkimRecord,
+ KeyType: utils.PtrTo(keyType),
+ HashAlgorithms: &hashAlgorithms,
+ SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
+ KeySize: parseKeySize(keyType, tags["p"]),
+ Valid: true,
+ }
+}
+
+func signingAlgorithmPtr(a string) *string {
+ if a == "" {
+ return nil
+ }
+ return &a
+}
+
+// validateDKIM performs basic DKIM record validation.
+func (d *DNSAnalyzer) validateDKIM(record string) bool {
+ if !strings.Contains(record, "p=") {
+ return false
+ }
+
+ // If v= is present, it must be DKIM1
+ if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
+ return false
+ }
+
+ return true
+}
+
+func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
+ if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
+ return 0
+ }
+
+ hasValid := false
+ for _, dkim := range *results.DkimRecords {
+ if dkim.Valid {
+ hasValid = true
+ break
+ }
+ }
+
+ if !hasValid {
+ return 25
+ }
+
+ score = 100
+
+ // Apply security penalties on the best valid record
+ for _, dkim := range *results.DkimRecords {
+ if !dkim.Valid {
+ continue
+ }
+
+ // SHA-1 signing is deprecated (RFC 8301)
+ if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") {
+ if score > 60 {
+ score = 60
+ }
+ }
+
+ // Key size penalties apply only to RSA
+ keyType := ""
+ if dkim.KeyType != nil {
+ keyType = strings.ToLower(*dkim.KeyType)
+ }
+ if keyType == "rsa" || keyType == "" {
+ if dkim.KeySize != nil {
+ switch {
+ case *dkim.KeySize < 1024:
+ if score > 25 {
+ score = 25
+ }
+ case *dkim.KeySize < 2048:
+ if score > 75 {
+ score = 75
+ }
+ }
+ }
+ }
+ // Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty.
+ }
+
+ return
+}
diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go
new file mode 100644
index 0000000..40e28a5
--- /dev/null
+++ b/pkg/analyzer/dns_dkim_test.go
@@ -0,0 +1,409 @@
+// 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 (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/base64"
+ "testing"
+ "time"
+)
+
+func TestParseDKIMSignatures(t *testing.T) {
+ tests := []struct {
+ name string
+ signatures []string
+ expected []DKIMHeader
+ }{
+ {
+ name: "Empty input",
+ signatures: nil,
+ expected: nil,
+ },
+ {
+ name: "Empty string",
+ signatures: []string{""},
+ expected: nil,
+ },
+ {
+ name: "Simple Gmail-style",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
+ },
+ expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Microsoft 365 style",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
+ },
+ expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Tab-folded multiline (Postfix-style)",
+ signatures: []string{
+ "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
+ },
+ expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Space-folded multiline (RFC-style)",
+ signatures: []string{
+ "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
+ },
+ expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "d= and s= on separate continuation lines",
+ signatures: []string{
+ "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
+ },
+ expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "No space after semicolons",
+ signatures: []string{
+ `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
+ },
+ expected: []DKIMHeader{{Domain: "example.net", Selector: "mail", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Multiple spaces after semicolons",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Ed25519 signature (RFC 8463)",
+ signatures: []string{
+ "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
+ },
+ expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}},
+ },
+ {
+ name: "Multiple signatures (ESP double-signing)",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
+ },
+ expected: []DKIMHeader{
+ {Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"},
+ {Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"},
+ },
+ },
+ {
+ name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
+ signatures: []string{
+ `v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
+ },
+ expected: []DKIMHeader{
+ {Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"},
+ {Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"},
+ },
+ },
+ {
+ name: "Amazon SES long selectors",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
+ `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
+ },
+ expected: []DKIMHeader{
+ {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"},
+ {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"},
+ },
+ },
+ {
+ name: "Subdomain in d=",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Deeply nested subdomain",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Selector with hyphens (Microsoft 365 custom domain style)",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Selector with dots",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Single-character selector",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Postmark-style timestamp selector, s= before d=",
+ signatures: []string{
+ `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
+ },
+ expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm", Algorithm: "rsa-sha1"}},
+ },
+ {
+ name: "d= and s= at the very end",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
+ },
+ expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Full tag set",
+ signatures: []string{
+ `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
+ },
+ expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
+ },
+ {
+ name: "Missing d= tag",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
+ },
+ expected: nil,
+ },
+ {
+ name: "Missing s= tag",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
+ },
+ expected: nil,
+ },
+ {
+ name: "Missing both d= and s= tags",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
+ },
+ expected: nil,
+ },
+ {
+ name: "Mix of valid and invalid signatures",
+ signatures: []string{
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
+ },
+ expected: []DKIMHeader{
+ {Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"},
+ {Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parseDKIMSignatures(tt.signatures)
+ if len(result) != len(tt.expected) {
+ t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
+ }
+ for i := range tt.expected {
+ if result[i].Domain != tt.expected[i].Domain {
+ t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
+ }
+ if result[i].Selector != tt.expected[i].Selector {
+ t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
+ }
+ if result[i].Algorithm != tt.expected[i].Algorithm {
+ t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm)
+ }
+ }
+ })
+ }
+}
+
+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 TestParseDKIMTags(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ wantTags map[string]string
+ }{
+ {
+ name: "standard RSA record",
+ record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256",
+ wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"},
+ },
+ {
+ name: "ed25519 record",
+ record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS",
+ wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"},
+ },
+ {
+ name: "missing k= defaults",
+ record: "v=DKIM1; p=MIIBI",
+ wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"},
+ },
+ {
+ name: "empty record",
+ record: "",
+ wantTags: map[string]string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseDKIMTags(tt.record)
+ for key, want := range tt.wantTags {
+ if got[key] != want {
+ t.Errorf("tag %q = %q, want %q", key, got[key], want)
+ }
+ }
+ })
+ }
+}
+
+func TestParseKeySize(t *testing.T) {
+ // Generate a real RSA key for testing
+ rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024)
+ rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+ der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey)
+ der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey)
+
+ p1024 := base64.StdEncoding.EncodeToString(der1024)
+ p2048 := base64.StdEncoding.EncodeToString(der2048)
+
+ tests := []struct {
+ name string
+ keyType string
+ p string
+ want *int
+ }{
+ {
+ name: "RSA 1024",
+ keyType: "rsa",
+ p: p1024,
+ want: intPtr(1024),
+ },
+ {
+ name: "RSA 2048",
+ keyType: "rsa",
+ p: p2048,
+ want: intPtr(2048),
+ },
+ {
+ name: "Ed25519 always 256",
+ keyType: "ed25519",
+ p: "11qYAYKxCrfVS",
+ want: intPtr(256),
+ },
+ {
+ name: "Unknown key type",
+ keyType: "unknown",
+ p: "somedata",
+ want: nil,
+ },
+ {
+ name: "Invalid RSA base64",
+ keyType: "rsa",
+ p: "!!!not-base64!!!",
+ want: nil,
+ },
+ {
+ name: "Empty k= defaults to RSA",
+ keyType: "",
+ p: p2048,
+ want: intPtr(2048),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseKeySize(tt.keyType, tt.p)
+ if tt.want == nil {
+ if got != nil {
+ t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want)
+ }
+ if *got != *tt.want {
+ t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want)
+ }
+ })
+ }
+}
+
+func intPtr(v int) *int { return &v }
diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go
new file mode 100644
index 0000000..b89500b
--- /dev/null
+++ b/pkg/analyzer/dns_dmarc.go
@@ -0,0 +1,314 @@
+// 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"
+ "strconv"
+ "strings"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2}
+
+// lookupDMARCAt queries _dmarc. and returns the raw DMARC1 TXT record.
+// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred.
+func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) {
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain))
+ if lookupErr != nil {
+ if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound {
+ return "", true, nil
+ }
+ return "", false, lookupErr
+ }
+
+ for _, txt := range txtRecords {
+ if strings.HasPrefix(txt, "v=DMARC1") {
+ return txt, false, nil
+ }
+ }
+ return "", true, nil
+}
+
+// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model.
+func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
+ tags := parseDKIMTags(rawRecord)
+
+ // Policy
+ policy := "unknown"
+ switch tags["p"] {
+ case "none", "quarantine", "reject":
+ policy = tags["p"]
+ }
+
+ // SPF alignment (default: relaxed)
+ spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
+ if tags["aspf"] == "s" {
+ spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
+ }
+
+ // DKIM alignment (default: relaxed)
+ dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
+ if tags["adkim"] == "s" {
+ dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
+ }
+
+ // Subdomain policy
+ var subdomainPolicy *model.DMARCRecordSubdomainPolicy
+ switch tags["sp"] {
+ case "none", "quarantine", "reject":
+ subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"]))
+ }
+
+ // Non-existent subdomain policy (DMARCbis np=)
+ var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy
+ switch tags["np"] {
+ case "none", "quarantine", "reject":
+ nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"]))
+ }
+
+ // Percentage (pct=, deprecated in DMARCbis)
+ var percentage *int
+ if pctStr, ok := tags["pct"]; ok {
+ if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 {
+ percentage = &pct
+ }
+ }
+
+ // Test mode (DMARCbis t=)
+ var testMode *bool
+ if t, ok := tags["t"]; ok {
+ v := t == "y"
+ testMode = &v
+ }
+
+ // PSD (DMARCbis psd=)
+ var psd *model.DMARCRecordPsd
+ switch tags["psd"] {
+ case "y", "n", "u":
+ psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"]))
+ }
+
+ rec := &model.DMARCRecord{
+ Domain: &foundDomain,
+ Record: &rawRecord,
+ Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
+ SubdomainPolicy: subdomainPolicy,
+ NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
+ Percentage: percentage,
+ TestMode: testMode,
+ Psd: psd,
+ SpfAlignment: spfAlignment,
+ DkimAlignment: dkimAlignment,
+ }
+ if percentage != nil {
+ rec.DeprecatedPct = utils.PtrTo(true)
+ }
+ if _, ok := tags["rf"]; ok {
+ rec.DeprecatedRf = utils.PtrTo(true)
+ }
+ if _, ok := tags["ri"]; ok {
+ rec.DeprecatedRi = utils.PtrTo(true)
+ }
+
+ if !d.validateDMARC(rawRecord) {
+ rec.Valid = false
+ rec.Error = utils.PtrTo("DMARC record appears malformed")
+ return rec
+ }
+
+ rec.Valid = true
+ return rec
+}
+
+// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10).
+// It queries _dmarc. and walks up the label hierarchy until a valid DMARC
+// record is found or all labels are exhausted. Maximum 8 DNS queries per message.
+// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label
+// suffix before resuming normally (to stay within the 8-query budget).
+// Single-label (TLD) records are only accepted when they carry psd=y.
+func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) {
+ labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".")
+ n := len(labels)
+
+ for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 {
+ current := strings.Join(labels[i:], ".")
+
+ raw, notFound, lookupErr := d.lookupDMARCAt(current)
+ if lookupErr != nil {
+ return "", "", lookupErr
+ }
+ if !notFound {
+ // Single-label (TLD) records are only used when the record explicitly opts in.
+ if !strings.Contains(current, ".") {
+ if d.extractDMARCPSDValue(raw) != "y" {
+ break
+ }
+ }
+ return raw, current, nil
+ }
+
+ // DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the
+ // 7-label suffix for the next query rather than stepping one label at a time.
+ if i == 0 && n >= 8 {
+ i = n - 8 // the outer i++ will land at n-7 (7 labels from the right)
+ }
+ }
+
+ return "", "", nil
+}
+
+// checkDMARCRecord looks up and validates the DMARC record for a domain using
+// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the
+// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC
+// experimental fallback.
+func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
+ raw, foundDomain, err := d.walkDNSForDMARC(domain)
+ if err != nil {
+ return &model.DMARCRecord{
+ Valid: false,
+ Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
+ }
+ }
+ if foundDomain == "" {
+ return &model.DMARCRecord{
+ Valid: false,
+ Error: utils.PtrTo("No DMARC record found"),
+ }
+ }
+ return d.parseDMARCRecord(foundDomain, raw)
+}
+
+// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent.
+// Used during DNS Tree Walk before full record parsing.
+func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
+ v := parseDKIMTags(record)["psd"]
+ switch v {
+ case "y", "n", "u":
+ return v
+ }
+ return ""
+}
+
+// validateDMARC performs basic DMARC record validation.
+// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
+// rua= but no p= is treated as p=none and considered valid.
+func (d *DNSAnalyzer) validateDMARC(record string) bool {
+ if !strings.HasPrefix(record, "v=DMARC1") {
+ return false
+ }
+
+ // p= absent is allowed in DMARCbis when rua= is present (treated as p=none).
+ if !strings.Contains(record, "p=") {
+ return strings.Contains(record, "rua=")
+ }
+
+ return true
+}
+
+func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
+ if results.DmarcRecord == nil {
+ return
+ }
+
+ if !results.DmarcRecord.Valid {
+ if results.DmarcRecord.Record != nil {
+ // Partial credit if a DMARC record exists but has issues
+ score += 20
+ }
+ return
+ }
+
+ score += 50
+
+ // Determine effective policy: DMARCbis t=y downgrades policy one level.
+ effectivePolicy := "none"
+ if results.DmarcRecord.Policy != nil {
+ effectivePolicy = string(*results.DmarcRecord.Policy)
+ }
+ testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode
+ if testMode {
+ switch effectivePolicy {
+ case "reject":
+ effectivePolicy = "quarantine"
+ case "quarantine":
+ effectivePolicy = "none"
+ }
+ }
+
+ // Bonus/penalty for policy strength
+ switch effectivePolicy {
+ case "reject":
+ score += 25
+ case "none":
+ score -= 25
+ }
+
+ // Bonus points for strict alignment modes
+ if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
+ score += 5
+ }
+ if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
+ score += 5
+ }
+
+ // Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker
+ if results.DmarcRecord.SubdomainPolicy != nil {
+ subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
+ if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] {
+ score += 15
+ } else {
+ score -= 15
+ }
+ } else {
+ score += 15 // inherits main policy — good default
+ }
+
+ // Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker
+ effectiveSubPolicy := effectivePolicy
+ if results.DmarcRecord.SubdomainPolicy != nil {
+ effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
+ }
+ if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
+ score += 15 // inherits subdomain/main policy — good default
+ } else if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] {
+ score += 15
+ } else {
+ score -= 15
+ }
+
+ // pct= scaling (deprecated in DMARCbis, kept for backward compatibility).
+ // pct=0 is an anti-pattern: score it as zero enforcement.
+ if results.DmarcRecord.Percentage != nil {
+ pct := *results.DmarcRecord.Percentage
+ score = score * pct / 100
+ }
+
+ return
+}
diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go
new file mode 100644
index 0000000..5c34a32
--- /dev/null
+++ b/pkg/analyzer/dns_dmarc_test.go
@@ -0,0 +1,592 @@
+// 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"
+ "testing"
+ "time"
+
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// mockDNSResolver maps domain names to TXT records for testing.
+// An entry with value nil means NXDOMAIN; an error value triggers a DNS error.
+type mockDNSResolver struct {
+ txt map[string][]string
+ err map[string]error
+}
+
+func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
+ if err, ok := m.err[name]; ok {
+ return nil, err
+ }
+ if records, ok := m.txt[name]; ok {
+ return records, nil
+ }
+ return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
+}
+
+func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) {
+ return nil, nil
+}
+func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
+ return nil, nil
+}
+func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) {
+ return nil, nil
+}
+
+func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer {
+ if errMap == nil {
+ errMap = map[string]error{}
+ }
+ return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap})
+}
+
+func TestCheckDMARCRecordFallback(t *testing.T) {
+ const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
+ const subRecord = "v=DMARC1; p=reject"
+ const psdRecord = "v=DMARC1; p=none; psd=y"
+
+ tests := []struct {
+ name string
+ domain string
+ txt map[string][]string
+ errMap map[string]error
+ wantValid bool
+ wantDomain *string
+ wantErrSubst string
+ }{
+ {
+ name: "exact domain has DMARC record — no fallback",
+ domain: "mail.example.com",
+ txt: map[string][]string{
+ "_dmarc.mail.example.com": {subRecord},
+ "_dmarc.example.com": {orgRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("mail.example.com"),
+ },
+ {
+ name: "exact domain NXDOMAIN — tree walk reaches org domain",
+ domain: "mail.example.com",
+ txt: map[string][]string{
+ "_dmarc.example.com": {orgRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("example.com"),
+ },
+ {
+ name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain",
+ domain: "mail.example.com",
+ txt: map[string][]string{
+ "_dmarc.mail.example.com": {"some-other-txt"},
+ "_dmarc.example.com": {orgRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("example.com"),
+ },
+ {
+ name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk",
+ domain: "mail.example.com",
+ txt: map[string][]string{
+ "_dmarc.com": {psdRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("com"),
+ },
+ {
+ name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk",
+ domain: "mail.example.com",
+ txt: map[string][]string{
+ "_dmarc.com": {"v=DMARC1; p=none"},
+ },
+ wantValid: false,
+ wantErrSubst: "No DMARC record found",
+ },
+ {
+ name: "no record at any level",
+ domain: "mail.example.com",
+ txt: map[string][]string{},
+ wantValid: false,
+ wantErrSubst: "No DMARC record found",
+ },
+ {
+ name: "DNS error on exact domain — error returned",
+ domain: "mail.example.com",
+ errMap: map[string]error{
+ "_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
+ },
+ wantValid: false,
+ wantErrSubst: "SERVFAIL",
+ },
+ {
+ name: "domain already at org level — found immediately",
+ domain: "example.com",
+ txt: map[string][]string{
+ "_dmarc.example.com": {orgRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("example.com"),
+ },
+ {
+ name: "deep subdomain — tree walk finds record two levels up",
+ domain: "a.b.example.com",
+ txt: map[string][]string{
+ "_dmarc.example.com": {orgRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("example.com"),
+ },
+ {
+ name: "8-label domain — shortcut to 7-label suffix on miss",
+ domain: "a.b.c.d.e.f.example.com",
+ txt: map[string][]string{
+ "_dmarc.b.c.d.e.f.example.com": {orgRecord},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("b.c.d.e.f.example.com"),
+ },
+ {
+ name: "psd=n record stops tree walk at that level",
+ domain: "mail.sub.example.com",
+ txt: map[string][]string{
+ "_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"},
+ },
+ wantValid: true,
+ wantDomain: utils.PtrTo("sub.example.com"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ analyzer := newMockAnalyzer(tt.txt, tt.errMap)
+ result := analyzer.checkDMARCRecord(tt.domain)
+
+ if result.Valid != tt.wantValid {
+ t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid)
+ }
+ if tt.wantDomain != nil {
+ if result.Domain == nil {
+ t.Fatalf("Domain = nil, want %q", *tt.wantDomain)
+ }
+ if *result.Domain != *tt.wantDomain {
+ t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain)
+ }
+ }
+ if tt.wantErrSubst != "" {
+ if result.Error == nil {
+ t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst)
+ }
+ if !contains(*result.Error, tt.wantErrSubst) {
+ t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst)
+ }
+ }
+ })
+ }
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
+}
+
+func containsStr(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+func TestParseDMARCRecordPolicy(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) {
+ rec := analyzer.parseDMARCRecord("example.com", tt.record)
+ if rec.Policy == nil {
+ t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record)
+ }
+ if string(*rec.Policy) != tt.expectedPolicy {
+ t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy)
+ }
+ })
+ }
+}
+
+func TestParseDMARCRecordTestMode(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ wantMode *bool
+ }{
+ {
+ name: "t=y sets test mode",
+ record: "v=DMARC1; p=reject; t=y",
+ wantMode: utils.PtrTo(true),
+ },
+ {
+ name: "t=n explicitly disables test mode",
+ record: "v=DMARC1; p=reject; t=n",
+ wantMode: utils.PtrTo(false),
+ },
+ {
+ name: "absent t tag returns nil",
+ record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
+ wantMode: nil,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode
+ if tt.wantMode == nil {
+ if result != nil {
+ t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result)
+ }
+ } else {
+ if result == nil {
+ t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode)
+ }
+ if *result != *tt.wantMode {
+ t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode)
+ }
+ }
+ })
+ }
+}
+
+func TestParseDMARCRecordPSD(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ wantPSD *string
+ }{
+ {
+ name: "psd=y marks Public Suffix Domain",
+ record: "v=DMARC1; p=none; psd=y",
+ wantPSD: utils.PtrTo("y"),
+ },
+ {
+ name: "psd=n marks Org Domain boundary",
+ record: "v=DMARC1; p=reject; psd=n",
+ wantPSD: utils.PtrTo("n"),
+ },
+ {
+ name: "psd=u is explicit unknown",
+ record: "v=DMARC1; p=quarantine; psd=u",
+ wantPSD: utils.PtrTo("u"),
+ },
+ {
+ name: "absent psd tag returns nil",
+ record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
+ wantPSD: nil,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.parseDMARCRecord("example.com", tt.record).Psd
+ if tt.wantPSD == nil {
+ if result != nil {
+ t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result)
+ }
+ } else {
+ if result == nil {
+ t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD)
+ }
+ if string(*result) != *tt.wantPSD {
+ t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD)
+ }
+ }
+ })
+ }
+}
+
+func TestParseDMARCRecordDeprecatedTags(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ wantRf bool
+ wantRi bool
+ }{
+ {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false},
+ {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true},
+ {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false},
+ {name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false},
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := analyzer.parseDMARCRecord("example.com", tt.record)
+ gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf
+ gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi
+ if gotRf != tt.wantRf {
+ t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf)
+ }
+ if gotRi != tt.wantRi {
+ t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi)
+ }
+ })
+ }
+}
+
+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: "DMARCbis: p= absent but rua= present is valid (treated as p=none)",
+ record: "v=DMARC1; rua=mailto:dmarc@example.com",
+ expected: true,
+ },
+ {
+ name: "Invalid DMARC - no version",
+ record: "p=quarantine",
+ expected: false,
+ },
+ {
+ name: "Invalid DMARC - no policy and no rua",
+ 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 TestParseDMARCRecordAlignment(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expectedSPF string
+ expectedDKIM string
+ }{
+ {
+ name: "SPF strict, DKIM relaxed",
+ record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
+ expectedSPF: "strict",
+ expectedDKIM: "relaxed",
+ },
+ {
+ name: "SPF relaxed explicit, DKIM strict",
+ record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
+ expectedSPF: "relaxed",
+ expectedDKIM: "strict",
+ },
+ {
+ name: "Defaults when neither specified",
+ record: "v=DMARC1; p=quarantine",
+ expectedSPF: "relaxed",
+ expectedDKIM: "relaxed",
+ },
+ {
+ name: "Both strict in complex record",
+ record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
+ expectedSPF: "strict",
+ expectedDKIM: "strict",
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := analyzer.parseDMARCRecord("example.com", tt.record)
+ if rec.SpfAlignment == nil {
+ t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record)
+ }
+ if string(*rec.SpfAlignment) != tt.expectedSPF {
+ t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF)
+ }
+ if rec.DkimAlignment == nil {
+ t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record)
+ }
+ if string(*rec.DkimAlignment) != tt.expectedDKIM {
+ t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM)
+ }
+ })
+ }
+}
+
+func TestParseDMARCRecordSubdomainPolicy(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expectedSP *string
+ expectedNP *string
+ }{
+ {
+ name: "sp=none, no np",
+ record: "v=DMARC1; p=quarantine; sp=none",
+ expectedSP: utils.PtrTo("none"),
+ expectedNP: nil,
+ },
+ {
+ name: "sp=reject, np=reject",
+ record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100",
+ expectedSP: utils.PtrTo("quarantine"),
+ expectedNP: utils.PtrTo("reject"),
+ },
+ {
+ name: "No sp or np (both default)",
+ record: "v=DMARC1; p=quarantine",
+ expectedSP: nil,
+ expectedNP: nil,
+ },
+ {
+ name: "np=quarantine, no sp",
+ record: "v=DMARC1; p=reject; np=quarantine",
+ expectedSP: nil,
+ expectedNP: utils.PtrTo("quarantine"),
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := analyzer.parseDMARCRecord("example.com", tt.record)
+ if tt.expectedSP == nil {
+ if rec.SubdomainPolicy != nil {
+ t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy)
+ }
+ } else {
+ if rec.SubdomainPolicy == nil {
+ t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP)
+ }
+ if string(*rec.SubdomainPolicy) != *tt.expectedSP {
+ t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP)
+ }
+ }
+ if tt.expectedNP == nil {
+ if rec.NonexistentSubdomainPolicy != nil {
+ t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy)
+ }
+ } else {
+ if rec.NonexistentSubdomainPolicy == nil {
+ t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP)
+ }
+ if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP {
+ t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP)
+ }
+ }
+ })
+ }
+}
+
+func TestParseDMARCRecordPercentage(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expectedPercentage *int
+ }{
+ {name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)},
+ {name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)},
+ {name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)},
+ {name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil},
+ {name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil},
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage
+ if tt.expectedPercentage == nil {
+ if result != nil {
+ t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result)
+ }
+ } else {
+ if result == nil {
+ t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage)
+ }
+ if *result != *tt.expectedPercentage {
+ t.Errorf("parseDMARCRecord(%q).Percentage = %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..07e5ab9
--- /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/model"
+)
+
+// 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 *model.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..c48c9a4
--- /dev/null
+++ b/pkg/analyzer/dns_mx.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"
+
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// checkMXRecords looks up MX records for a domain
+func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ mxRecords, err := d.resolver.LookupMX(ctx, domain)
+ if err != nil {
+ return &[]model.MXRecord{
+ {
+ Valid: false,
+ Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
+ },
+ }
+ }
+
+ if len(mxRecords) == 0 {
+ return &[]model.MXRecord{
+ {
+ Valid: false,
+ Error: utils.PtrTo("No MX records found"),
+ },
+ }
+ }
+
+ var results []model.MXRecord
+ for _, mx := range mxRecords {
+ results = append(results, model.MXRecord{
+ Host: mx.Host,
+ Priority: mx.Pref,
+ Valid: true,
+ })
+ }
+
+ return &results
+}
+
+func (d *DNSAnalyzer) calculateMXScore(results *model.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..ccb1674
--- /dev/null
+++ b/pkg/analyzer/dns_spf.go
@@ -0,0 +1,368 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
+func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.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) *[]model.SPFRecord {
+ const maxDepth = 10 // Prevent infinite recursion
+
+ if depth > maxDepth {
+ return &[]model.SPFRecord{
+ {
+ Domain: &domain,
+ Valid: false,
+ Error: utils.PtrTo("Maximum SPF include depth exceeded"),
+ },
+ }
+ }
+
+ // Prevent circular references
+ if visited[domain] {
+ return &[]model.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 &[]model.SPFRecord{
+ {
+ Domain: &domain,
+ Valid: false,
+ Error: utils.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 &[]model.SPFRecord{
+ {
+ Domain: &domain,
+ Valid: false,
+ Error: utils.PtrTo("No SPF record found"),
+ },
+ }
+ }
+
+ var results []model.SPFRecord
+
+ if spfCount > 1 {
+ results = append(results, model.SPFRecord{
+ Domain: &domain,
+ Record: &spfRecord,
+ Valid: false,
+ Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
+ })
+ return &results
+ }
+
+ // Basic validation
+ validationErr := d.validateSPF(spfRecord, isMainRecord)
+
+ // Extract the "all" mechanism qualifier
+ var allQualifier *model.SPFRecordAllQualifier
+ var errMsg *string
+
+ if validationErr != nil {
+ errMsg = utils.PtrTo(validationErr.Error())
+ } else {
+ // Extract qualifier from the "all" mechanism
+ if strings.HasSuffix(spfRecord, " -all") {
+ allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
+ } else if strings.HasSuffix(spfRecord, " ~all") {
+ allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
+ } else if strings.HasSuffix(spfRecord, " +all") {
+ allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
+ } else if strings.HasSuffix(spfRecord, " ?all") {
+ allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
+ } else if strings.HasSuffix(spfRecord, " all") {
+ // Implicit + qualifier (default)
+ allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
+ }
+ }
+
+ results = append(results, model.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 *model.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
index 12a6bd0..bba4503 100644
--- a/pkg/analyzer/dns_test.go
+++ b/pkg/analyzer/dns_test.go
@@ -22,12 +22,8 @@
package analyzer
import (
- "net/mail"
- "strings"
"testing"
"time"
-
- "git.happydns.org/happyDeliver/internal/api"
)
func TestNewDNSAnalyzer(t *testing.T) {
@@ -60,761 +56,3 @@ func TestNewDNSAnalyzer(t *testing.T) {
})
}
}
-
-func TestExtractDomain(t *testing.T) {
- tests := []struct {
- name string
- fromAddress string
- expectedDomain string
- }{
- {
- name: "Valid email",
- fromAddress: "user@example.com",
- expectedDomain: "example.com",
- },
- {
- name: "Email with subdomain",
- fromAddress: "user@mail.example.com",
- expectedDomain: "mail.example.com",
- },
- {
- name: "Email with uppercase",
- fromAddress: "User@Example.COM",
- expectedDomain: "example.com",
- },
- {
- name: "Invalid email (no @)",
- fromAddress: "invalid-email",
- expectedDomain: "",
- },
- {
- name: "Empty email",
- fromAddress: "",
- expectedDomain: "",
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- email := &EmailMessage{
- Header: make(mail.Header),
- }
- if tt.fromAddress != "" {
- email.From = &mail.Address{
- Address: tt.fromAddress,
- }
- }
-
- domain := analyzer.extractDomain(email)
- if domain != tt.expectedDomain {
- t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
- }
- })
- }
-}
-
-func TestValidateSPF(t *testing.T) {
- tests := []struct {
- name string
- record string
- expected bool
- }{
- {
- name: "Valid SPF with -all",
- record: "v=spf1 include:_spf.example.com -all",
- expected: true,
- },
- {
- name: "Valid SPF with ~all",
- record: "v=spf1 ip4:192.0.2.0/24 ~all",
- expected: true,
- },
- {
- name: "Valid SPF with +all",
- record: "v=spf1 +all",
- expected: true,
- },
- {
- name: "Valid SPF with ?all",
- record: "v=spf1 mx ?all",
- expected: true,
- },
- {
- name: "Invalid SPF - no version",
- record: "include:_spf.example.com -all",
- expected: false,
- },
- {
- name: "Invalid SPF - no all mechanism",
- record: "v=spf1 include:_spf.example.com",
- expected: false,
- },
- {
- name: "Invalid SPF - wrong version",
- record: "v=spf2 include:_spf.example.com -all",
- expected: false,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.validateSPF(tt.record)
- if result != tt.expected {
- t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected)
- }
- })
- }
-}
-
-func TestValidateDKIM(t *testing.T) {
- tests := []struct {
- name string
- record string
- expected bool
- }{
- {
- name: "Valid DKIM with version",
- record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
- expected: true,
- },
- {
- name: "Valid DKIM without version",
- record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
- expected: true,
- },
- {
- name: "Invalid DKIM - no public key",
- record: "v=DKIM1; k=rsa",
- expected: false,
- },
- {
- name: "Invalid DKIM - wrong version",
- record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
- expected: false,
- },
- {
- name: "Invalid DKIM - empty",
- record: "",
- expected: false,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.validateDKIM(tt.record)
- if result != tt.expected {
- t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
- }
- })
- }
-}
-
-func TestExtractDMARCPolicy(t *testing.T) {
- tests := []struct {
- name string
- record string
- expectedPolicy string
- }{
- {
- name: "Policy none",
- record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
- expectedPolicy: "none",
- },
- {
- name: "Policy quarantine",
- record: "v=DMARC1; p=quarantine; pct=100",
- expectedPolicy: "quarantine",
- },
- {
- name: "Policy reject",
- record: "v=DMARC1; p=reject; sp=reject",
- expectedPolicy: "reject",
- },
- {
- name: "No policy",
- record: "v=DMARC1",
- expectedPolicy: "unknown",
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.extractDMARCPolicy(tt.record)
- if result != tt.expectedPolicy {
- t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
- }
- })
- }
-}
-
-func TestValidateDMARC(t *testing.T) {
- tests := []struct {
- name string
- record string
- expected bool
- }{
- {
- name: "Valid DMARC",
- record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
- expected: true,
- },
- {
- name: "Valid DMARC minimal",
- record: "v=DMARC1; p=none",
- expected: true,
- },
- {
- name: "Invalid DMARC - no version",
- record: "p=quarantine",
- expected: false,
- },
- {
- name: "Invalid DMARC - no policy",
- record: "v=DMARC1",
- expected: false,
- },
- {
- name: "Invalid DMARC - wrong version",
- record: "v=DMARC2; p=reject",
- expected: false,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.validateDMARC(tt.record)
- if result != tt.expected {
- t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
- }
- })
- }
-}
-
-func TestGenerateMXCheck(t *testing.T) {
- tests := []struct {
- name string
- results *DNSResults
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "Valid MX records",
- results: &DNSResults{
- Domain: "example.com",
- MXRecords: []MXRecord{
- {Host: "mail.example.com", Priority: 10, Valid: true},
- {Host: "mail2.example.com", Priority: 20, Valid: true},
- },
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "No MX records",
- results: &DNSResults{
- Domain: "example.com",
- MXRecords: []MXRecord{
- {Valid: false, Error: "No MX records found"},
- },
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- {
- name: "MX lookup failed",
- results: &DNSResults{
- Domain: "example.com",
- MXRecords: []MXRecord{
- {Valid: false, Error: "DNS lookup failed"},
- },
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateMXCheck(tt.results)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Score != tt.expectedScore {
- t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
- }
- if check.Category != api.Dns {
- t.Errorf("Category = %v, want %v", check.Category, api.Dns)
- }
- })
- }
-}
-
-func TestGenerateSPFCheck(t *testing.T) {
- tests := []struct {
- name string
- spf *SPFRecord
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "Valid SPF",
- spf: &SPFRecord{
- Record: "v=spf1 include:_spf.example.com -all",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "Invalid SPF",
- spf: &SPFRecord{
- Record: "v=spf1 invalid syntax",
- Valid: false,
- Error: "SPF record appears malformed",
- },
- expectedStatus: api.CheckStatusWarn,
- expectedScore: 0.5,
- },
- {
- name: "No SPF record",
- spf: &SPFRecord{
- Valid: false,
- Error: "No SPF record found",
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateSPFCheck(tt.spf)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Score != tt.expectedScore {
- t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
- }
- if check.Category != api.Dns {
- t.Errorf("Category = %v, want %v", check.Category, api.Dns)
- }
- })
- }
-}
-
-func TestGenerateDKIMCheck(t *testing.T) {
- tests := []struct {
- name string
- dkim *DKIMRecord
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "Valid DKIM",
- dkim: &DKIMRecord{
- Selector: "default",
- Domain: "example.com",
- Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "Invalid DKIM",
- dkim: &DKIMRecord{
- Selector: "default",
- Domain: "example.com",
- Valid: false,
- Error: "No DKIM record found",
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateDKIMCheck(tt.dkim)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Score != tt.expectedScore {
- t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
- }
- if check.Category != api.Dns {
- t.Errorf("Category = %v, want %v", check.Category, api.Dns)
- }
- if !strings.Contains(check.Name, tt.dkim.Selector) {
- t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
- }
- })
- }
-}
-
-func TestGenerateDMARCCheck(t *testing.T) {
- tests := []struct {
- name string
- dmarc *DMARCRecord
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "Valid DMARC - reject",
- dmarc: &DMARCRecord{
- Record: "v=DMARC1; p=reject",
- Policy: "reject",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "Valid DMARC - quarantine",
- dmarc: &DMARCRecord{
- Record: "v=DMARC1; p=quarantine",
- Policy: "quarantine",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "Valid DMARC - none",
- dmarc: &DMARCRecord{
- Record: "v=DMARC1; p=none",
- Policy: "none",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 1.0,
- },
- {
- name: "No DMARC record",
- dmarc: &DMARCRecord{
- Valid: false,
- Error: "No DMARC record found",
- },
- expectedStatus: api.CheckStatusFail,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateDMARCCheck(tt.dmarc)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Score != tt.expectedScore {
- t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
- }
- if check.Category != api.Dns {
- t.Errorf("Category = %v, want %v", check.Category, api.Dns)
- }
-
- // Check that advice mentions policy for valid DMARC
- if tt.dmarc.Valid && check.Advice != nil {
- if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
- t.Error("Advice should mention 'none' policy")
- }
- }
- })
- }
-}
-
-func TestGenerateDNSChecks(t *testing.T) {
- tests := []struct {
- name string
- results *DNSResults
- minChecks int
- }{
- {
- name: "Nil results",
- results: nil,
- minChecks: 0,
- },
- {
- name: "Complete results",
- results: &DNSResults{
- Domain: "example.com",
- MXRecords: []MXRecord{
- {Host: "mail.example.com", Priority: 10, Valid: true},
- },
- SPFRecord: &SPFRecord{
- Record: "v=spf1 include:_spf.example.com -all",
- Valid: true,
- },
- DKIMRecords: []DKIMRecord{
- {
- Selector: "default",
- Domain: "example.com",
- Valid: true,
- },
- },
- DMARCRecord: &DMARCRecord{
- Record: "v=DMARC1; p=quarantine",
- Policy: "quarantine",
- Valid: true,
- },
- },
- minChecks: 4, // MX, SPF, DKIM, DMARC
- },
- {
- name: "Partial results",
- results: &DNSResults{
- Domain: "example.com",
- MXRecords: []MXRecord{
- {Host: "mail.example.com", Priority: 10, Valid: true},
- },
- },
- minChecks: 1, // Only MX
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- checks := analyzer.GenerateDNSChecks(tt.results)
-
- if len(checks) < tt.minChecks {
- t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
- }
-
- // Verify all checks have the DNS category
- for _, check := range checks {
- if check.Category != api.Dns {
- t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
- }
- }
- })
- }
-}
-
-func TestAnalyzeDNS_NoDomain(t *testing.T) {
- analyzer := NewDNSAnalyzer(5 * time.Second)
- email := &EmailMessage{
- Header: make(mail.Header),
- // No From address
- }
-
- results := analyzer.AnalyzeDNS(email, nil)
-
- if results == nil {
- t.Fatal("Expected results, got nil")
- }
-
- if len(results.Errors) == 0 {
- t.Error("Expected error when no domain can be extracted")
- }
-}
-
-func TestExtractBIMITag(t *testing.T) {
- tests := []struct {
- name string
- record string
- tag string
- expectedValue string
- }{
- {
- name: "Extract logo URL (l tag)",
- record: "v=BIMI1; l=https://example.com/logo.svg",
- tag: "l",
- expectedValue: "https://example.com/logo.svg",
- },
- {
- name: "Extract VMC URL (a tag)",
- record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
- tag: "a",
- expectedValue: "https://example.com/vmc.pem",
- },
- {
- name: "Tag not found",
- record: "v=BIMI1; l=https://example.com/logo.svg",
- tag: "a",
- expectedValue: "",
- },
- {
- name: "Tag with spaces",
- record: "v=BIMI1; l= https://example.com/logo.svg ",
- tag: "l",
- expectedValue: "https://example.com/logo.svg",
- },
- {
- name: "Empty record",
- record: "",
- tag: "l",
- expectedValue: "",
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.extractBIMITag(tt.record, tt.tag)
- if result != tt.expectedValue {
- t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
- }
- })
- }
-}
-
-func TestValidateBIMI(t *testing.T) {
- tests := []struct {
- name string
- record string
- expected bool
- }{
- {
- name: "Valid BIMI with logo URL",
- record: "v=BIMI1; l=https://example.com/logo.svg",
- expected: true,
- },
- {
- name: "Valid BIMI with logo and VMC",
- record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
- expected: true,
- },
- {
- name: "Invalid BIMI - no version",
- record: "l=https://example.com/logo.svg",
- expected: false,
- },
- {
- name: "Invalid BIMI - wrong version",
- record: "v=BIMI2; l=https://example.com/logo.svg",
- expected: false,
- },
- {
- name: "Invalid BIMI - no logo URL",
- record: "v=BIMI1",
- expected: false,
- },
- {
- name: "Invalid BIMI - empty",
- record: "",
- expected: false,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.validateBIMI(tt.record)
- if result != tt.expected {
- t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
- }
- })
- }
-}
-
-func TestGenerateBIMICheck(t *testing.T) {
- tests := []struct {
- name string
- bimi *BIMIRecord
- expectedStatus api.CheckStatus
- expectedScore float32
- }{
- {
- name: "Valid BIMI with logo only",
- bimi: &BIMIRecord{
- Selector: "default",
- Domain: "example.com",
- Record: "v=BIMI1; l=https://example.com/logo.svg",
- LogoURL: "https://example.com/logo.svg",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 0.0, // BIMI doesn't contribute to score
- },
- {
- name: "Valid BIMI with VMC",
- bimi: &BIMIRecord{
- Selector: "default",
- Domain: "example.com",
- Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
- LogoURL: "https://example.com/logo.svg",
- VMCURL: "https://example.com/vmc.pem",
- Valid: true,
- },
- expectedStatus: api.CheckStatusPass,
- expectedScore: 0.0,
- },
- {
- name: "No BIMI record (optional)",
- bimi: &BIMIRecord{
- Selector: "default",
- Domain: "example.com",
- Valid: false,
- Error: "No BIMI record found",
- },
- expectedStatus: api.CheckStatusInfo,
- expectedScore: 0.0,
- },
- {
- name: "Invalid BIMI record",
- bimi: &BIMIRecord{
- Selector: "default",
- Domain: "example.com",
- Record: "v=BIMI1",
- Valid: false,
- Error: "BIMI record appears malformed",
- },
- expectedStatus: api.CheckStatusWarn,
- expectedScore: 0.0,
- },
- }
-
- analyzer := NewDNSAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateBIMICheck(tt.bimi)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Score != tt.expectedScore {
- t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
- }
- if check.Category != api.Dns {
- t.Errorf("Category = %v, want %v", check.Category, api.Dns)
- }
- if check.Name != "BIMI Record" {
- t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
- }
-
- // Check details for valid BIMI with VMC
- if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
- if !strings.Contains(*check.Details, "VMC URL") {
- t.Error("Details should contain VMC URL for valid BIMI with VMC")
- }
- }
- })
- }
-}
diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go
new file mode 100644
index 0000000..6d7b547
--- /dev/null
+++ b/pkg/analyzer/headers.go
@@ -0,0 +1,697 @@
+// 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/model"
+ "git.happydns.org/happyDeliver/internal/utils"
+)
+
+// 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 *model.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 model.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 *model.AuthenticationResults) *model.HeaderAnalysis {
+ if email == nil {
+ return nil
+ }
+
+ analysis := &model.HeaderAnalysis{}
+
+ // Check for proper MIME structure
+ analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
+
+ // Initialize headers map
+ headers := make(map[string]model.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) *model.HeaderCheck {
+ value := email.GetHeaderValue(headerName)
+ present := email.HasHeader(headerName) && value != ""
+
+ importanceEnum := model.HeaderCheckImportance(importance)
+ check := &model.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 *model.AuthenticationResults) *model.DomainAlignment {
+ alignment := &model.DomainAlignment{
+ Aligned: utils.PtrTo(true),
+ RelaxedAligned: utils.PtrTo(true),
+ }
+
+ // Extract From domain
+ fromAddr := email.GetHeaderValue("From")
+ if fromAddr != "" {
+ domain := h.extractDomain(fromAddr)
+ if domain != "" {
+ alignment.FromDomain = &domain
+ // Extract organizational domain
+ orgDomain := 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 := getOrganizationalDomain(domain)
+ alignment.ReturnPathOrgDomain = &orgDomain
+ }
+ }
+
+ // Extract DKIM domains from authentication results
+ var dkimDomains []model.DKIMDomainInfo
+ if authResults != nil && authResults.Dkim != nil {
+ for _, dkim := range *authResults.Dkim {
+ if dkim.Domain != nil && *dkim.Domain != "" {
+ domain := *dkim.Domain
+ orgDomain := getOrganizationalDomain(domain)
+ dkimDomains = append(dkimDomains, model.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 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) []model.HeaderIssue {
+ var issues []model.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, model.HeaderIssue{
+ Header: header,
+ Severity: model.HeaderIssueSeverityCritical,
+ Message: fmt.Sprintf("Required header '%s' is missing", header),
+ Advice: utils.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, model.HeaderIssue{
+ Header: "Message-ID",
+ Severity: model.HeaderIssueSeverityMedium,
+ Message: "Message-ID format is invalid",
+ Advice: utils.PtrTo("Use proper Message-ID format: "),
+ })
+ }
+
+ return issues
+}
+
+// parseReceivedChain extracts the chain of Received headers from an email
+func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
+ if email == nil || email.Header == nil {
+ return nil
+ }
+
+ receivedHeaders := email.Header["Received"]
+ if len(receivedHeaders) == 0 {
+ return nil
+ }
+
+ var chain []model.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) *model.ReceivedHop {
+ hop := &model.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..d7469d7
--- /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/model"
+)
+
+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, []model.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 []model.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 []model.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 []model.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 []model.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 []model.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 *model.AuthenticationResults
+ if len(tt.dkimDomains) > 0 {
+ dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
+ for _, domain := range tt.dkimDomains {
+ dkimResults = append(dkimResults, model.AuthResult{
+ Result: model.AuthResultResultPass,
+ Domain: &domain,
+ })
+ }
+ authResults = &model.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/pkg/analyzer/parser.go b/pkg/analyzer/parser.go
index 13c012c..00de151 100644
--- a/pkg/analyzer/parser.go
+++ b/pkg/analyzer/parser.go
@@ -211,8 +211,27 @@ func buildRawHeaders(header mail.Header) string {
}
// GetAuthenticationResults extracts Authentication-Results headers
-func (e *EmailMessage) GetAuthenticationResults() []string {
- return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
+// If receiverHostname is provided, only returns headers that begin with that hostname
+func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
+ allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
+
+ // If no hostname specified, return all results
+ if receiverHostname == "" {
+ return allResults
+ }
+
+ // Filter results that begin with the specified hostname
+ var filtered []string
+ prefix := receiverHostname + ";"
+ 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 +249,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 +321,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/pkg/analyzer/parser_test.go b/pkg/analyzer/parser_test.go
index 571f542..196e8e2 100644
--- a/pkg/analyzer/parser_test.go
+++ b/pkg/analyzer/parser_test.go
@@ -120,7 +120,7 @@ Body content.
t.Fatalf("Failed to parse email: %v", err)
}
- authResults := email.GetAuthenticationResults()
+ authResults := email.GetAuthenticationResults("example.com")
if len(authResults) != 2 {
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
}
diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go
index fb01ae0..31cccab 100644
--- a/pkg/analyzer/rbl.go
+++ b/pkg/analyzer/rbl.go
@@ -27,16 +27,22 @@ import (
"net"
"regexp"
"strings"
+ "sync"
"time"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
-// RBLChecker checks IP addresses against DNS-based blacklists
-type RBLChecker struct {
- Timeout time.Duration
- RBLs []string
- resolver *net.Resolver
+// DNSListChecker checks IP addresses against DNS-based block/allow lists.
+// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
+type DNSListChecker struct {
+ Timeout time.Duration
+ Lists []string
+ CheckAllIPs bool // Check all IPs found in headers, not just the first one
+ filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
+ resolver *net.Resolver
+ informationalSet map[string]bool // Lists whose hits don't count toward the score
}
// DefaultRBLs is a list of commonly used RBL providers
@@ -47,46 +53,83 @@ var DefaultRBLs = []string{
"b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
+ "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
+ "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
+ "psbl.surriel.com", // PSBL
+ "dnsbl.dronebl.org", // DroneBL
+ "bl.mailspike.net", // Mailspike BL
+ "z.mailspike.net", // Mailspike Z
+ "bl.rbl-dns.com", // RBL-DNS
+ "bl.nszones.com", // NSZones
+}
+
+// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
+// These are typically broader lists where being listed is less definitive.
+var DefaultInformationalRBLs = []string{
+ "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
+ "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
+}
+
+// DefaultDNSWLs is a list of commonly used DNSWL providers
+var DefaultDNSWLs = []string{
+ "list.dnswl.org", // DNSWL.org — the main DNS whitelist
+ "swl.spamhaus.org", // Spamhaus Safe Whitelist
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
-func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
+func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
- timeout = 5 * time.Second // Default timeout
+ timeout = 5 * time.Second
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
- return &RBLChecker{
- Timeout: timeout,
- RBLs: rbls,
- resolver: &net.Resolver{
- PreferGo: true,
- },
+ informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
+ for _, rbl := range DefaultInformationalRBLs {
+ informationalSet[rbl] = true
+ }
+ return &DNSListChecker{
+ Timeout: timeout,
+ Lists: rbls,
+ CheckAllIPs: checkAllIPs,
+ filterErrorCodes: true,
+ resolver: &net.Resolver{PreferGo: true},
+ informationalSet: informationalSet,
}
}
-// RBLResults represents the results of RBL checks
-type RBLResults struct {
- Checks []RBLCheck
- IPsChecked []string
- ListedCount int
+// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
+func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
+ if timeout == 0 {
+ timeout = 5 * time.Second
+ }
+ if len(dnswls) == 0 {
+ dnswls = DefaultDNSWLs
+ }
+ return &DNSListChecker{
+ Timeout: timeout,
+ Lists: dnswls,
+ CheckAllIPs: checkAllIPs,
+ filterErrorCodes: false,
+ resolver: &net.Resolver{PreferGo: true},
+ informationalSet: make(map[string]bool),
+ }
}
-// RBLCheck represents a single RBL check result
-type RBLCheck struct {
- IP string
- RBL string
- Listed bool
- Response string
- Error string
+// DNSListResults represents the results of DNS list checks
+type DNSListResults struct {
+ Checks map[string][]model.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 RBLs
-func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
- results := &RBLResults{}
+// CheckEmail checks all IPs found in the email headers against the configured lists
+func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
+ results := &DNSListResults{
+ Checks: make(map[string][]model.BlacklistCheck),
+ }
- // Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
@@ -94,42 +137,68 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results.IPsChecked = ips
- // Check each IP against all RBLs
for _, ip := range ips {
- for _, rbl := range r.RBLs {
- check := r.checkIP(ip, rbl)
- results.Checks = append(results.Checks, check)
+ 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) ([]model.BlacklistCheck, int, error) {
+ if !r.isPublicIP(ip) {
+ return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
+ }
+
+ checks := make([]model.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 *RBLChecker) extractIPs(email *EmailMessage) []string {
+func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
- // Get all Received headers
receivedHeaders := email.Header["Received"]
-
- // Regex patterns for IP addresses
- // Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
- // Look for IPs in Received headers
for _, received := range receivedHeaders {
- // Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
- // Skip private/reserved IPs
if !r.isPublicIP(match) {
continue
}
- // Avoid duplicates
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
@@ -137,13 +206,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
}
}
- // If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
- // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
- // Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
@@ -156,19 +222,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
-func (r *RBLChecker) isPublicIP(ipStr string) bool {
+func (r *DNSListChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
- // Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
- // Additional checks for reserved ranges
- // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() {
return false
}
@@ -176,233 +239,120 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
return true
}
-// checkIP checks a single IP against a single RBL
-func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
- check := RBLCheck{
- IP: ip,
- RBL: rbl,
+// checkIP checks a single IP against a single DNS list
+func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
+ check := model.BlacklistCheck{
+ Rbl: list,
}
- // Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
- check.Error = "Failed to reverse IP address"
+ check.Error = utils.PtrTo("Failed to reverse IP address")
return check
}
- // Construct DNSBL query: reversed-ip.rbl-domain
- query := fmt.Sprintf("%s.%s", reversedIP, rbl)
+ query := fmt.Sprintf("%s.%s", reversedIP, list)
- // Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
- // Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
- // Other DNS errors
- check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
+ check.Error = utils.PtrTo(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)
+ check.Response = utils.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 = utils.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 queries
+// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
// Example: 192.0.2.1 -> 1.2.0.192
-func (r *RBLChecker) reverseIP(ipStr string) string {
+func (r *DNSListChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
- // Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
- // Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
-// 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
- }
+// CalculateScore calculates the list contribution to deliverability.
+// Informational lists are not counted in the score.
+func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
+ scoringListCount := len(r.Lists) - len(r.informationalSet)
- 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.CheckSeverityLow),
- Advice: api.PtrTo("Unable to extract sender IP from email headers"),
- })
- return checks
- }
-
- // Create a summary check
- summaryCheck := r.generateSummaryCheck(results)
- checks = append(checks, summaryCheck)
-
- // Create individual checks for each listing
- for _, check := range results.Checks {
- if check.Listed {
- detailCheck := r.generateListingCheck(&check)
- checks = append(checks, detailCheck)
+ if forWhitelist {
+ if results.ListedCount >= scoringListCount {
+ return 100, "A++"
+ } else if results.ListedCount > 0 {
+ return 100, "A+"
+ } else {
+ return 95, "A"
}
}
- return checks
+ if results == nil || len(results.IPsChecked) == 0 {
+ return 100, ""
+ }
+
+ if results.ListedCount <= 0 || scoringListCount <= 0 {
+ return 100, "A+"
+ }
+
+ percentage := 100 - results.RelevantListedCount*100/scoringListCount
+ return percentage, ScoreToGrade(percentage)
}
-// generateSummaryCheck creates an overall RBL summary check
-func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
- check := api.Check{
- Category: api.Blacklist,
- Name: "RBL Summary",
- }
-
- score := r.GetBlacklistScore(results)
- check.Score = score
-
- totalChecks := len(results.Checks)
- listedCount := results.ListedCount
-
- if listedCount == 0 {
- check.Status = api.CheckStatusPass
- check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your sending IP has a good reputation")
- } else if listedCount == 1 {
- check.Status = api.CheckStatusWarn
- check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
- } else if listedCount <= 3 {
- check.Status = api.CheckStatusWarn
- check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
- } else {
- check.Status = api.CheckStatusFail
- check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
- check.Severity = api.PtrTo(api.CheckSeverityCritical)
- check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
- }
-
- // Add details about IPs checked
- if len(results.IPsChecked) > 0 {
- details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
- check.Details = &details
- }
-
- return check
-}
-
-// generateListingCheck creates a check for a specific RBL listing
-func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
- check := api.Check{
- Category: api.Blacklist,
- Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
- Status: api.CheckStatusFail,
- Score: 0.0,
- }
-
- check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
-
- // Determine severity based on which RBL
- if strings.Contains(rblCheck.RBL, "spamhaus") {
- check.Severity = api.PtrTo(api.CheckSeverityCritical)
- advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
- check.Advice = &advice
- } else if strings.Contains(rblCheck.RBL, "spamcop") {
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
- check.Advice = &advice
- } else {
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
- check.Advice = &advice
- }
-
- // Add response code details
- if rblCheck.Response != "" {
- details := fmt.Sprintf("Response: %s", rblCheck.Response)
- check.Details = &details
- }
-
- return check
-}
-
-// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
-func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
- seenIPs := make(map[string]bool)
+// 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 _, check := range results.Checks {
- if check.Listed && !seenIPs[check.IP] {
- listedIPs = append(listedIPs, check.IP)
- seenIPs[check.IP] = true
+ for ip, checks := range results.Checks {
+ for _, check := range checks {
+ if check.Listed {
+ listedIPs = append(listedIPs, ip)
+ break
+ }
}
}
return listedIPs
}
-// GetRBLsForIP returns all RBLs that list a specific IP
-func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
- var rbls []string
+// GetListsForIP returns all lists that match a specific IP
+func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
+ var lists []string
- for _, check := range results.Checks {
- if check.IP == ip && check.Listed {
- rbls = append(rbls, check.RBL)
+ if checks, exists := results.Checks[ip]; exists {
+ for _, check := range checks {
+ if check.Listed {
+ lists = append(lists, check.Rbl)
+ }
}
}
- return rbls
+ return lists
}
diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go
index 3a2fd44..f86f17b 100644
--- a/pkg/analyzer/rbl_test.go
+++ b/pkg/analyzer/rbl_test.go
@@ -23,11 +23,10 @@ package analyzer
import (
"net/mail"
- "strings"
"testing"
"time"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestNewRBLChecker(t *testing.T) {
@@ -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,72 @@ 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{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 1,
+ results: &DNSListResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 1,
+ RelevantListedCount: 1,
},
- expectedScore: 1.0,
+ expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational)
},
{
name: "Listed on 2 RBLs",
- results: &RBLResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 2,
+ results: &DNSListResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 2,
+ RelevantListedCount: 2,
},
- expectedScore: 0.5,
+ expectedScore: 84, // 100 - 2*100/12 = 84
},
{
name: "Listed on 3 RBLs",
- results: &RBLResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 3,
+ results: &DNSListResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 3,
+ RelevantListedCount: 3,
},
- expectedScore: 0.5,
+ expectedScore: 75, // 100 - 3*100/12 = 75
},
{
name: "Listed on 4+ RBLs",
- results: &RBLResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 4,
+ results: &DNSListResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 4,
+ RelevantListedCount: 4,
},
- expectedScore: 0.0,
+ expectedScore: 67, // 100 - 4*100/12 = 67
},
}
- 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, false)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@@ -335,215 +338,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.CheckSeverityCritical,
- },
- {
- name: "SpamCop listing",
- rblCheck: &RBLCheck{
- IP: "198.51.100.1",
- RBL: "bl.spamcop.net",
- Listed: true,
- Response: "127.0.0.2",
- },
- expectedStatus: api.CheckStatusFail,
- expectedSeverity: api.CheckSeverityHigh,
- },
- {
- name: "Other RBL listing",
- rblCheck: &RBLCheck{
- IP: "198.51.100.1",
- RBL: "dnsbl.sorbs.net",
- Listed: true,
- Response: "127.0.0.2",
- },
- expectedStatus: api.CheckStatusFail,
- expectedSeverity: api.CheckSeverityHigh,
- },
- }
-
- checker := NewRBLChecker(5*time.Second, nil)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := checker.generateListingCheck(tt.rblCheck)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Severity == nil || *check.Severity != tt.expectedSeverity {
- t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
- }
- if check.Category != api.Blacklist {
- t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
- }
- if !strings.Contains(check.Name, tt.rblCheck.RBL) {
- t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
- }
- })
- }
-}
-
-func TestGenerateRBLChecks(t *testing.T) {
- tests := []struct {
- name string
- results *RBLResults
- minChecks int
- }{
- {
- name: "Nil results",
- results: nil,
- minChecks: 0,
- },
- {
- name: "No IPs checked",
- results: &RBLResults{
- IPsChecked: []string{},
- },
- minChecks: 1, // Warning check
- },
- {
- name: "Not listed on any RBL",
- results: &RBLResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 0,
- Checks: []RBLCheck{
- {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
- {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
- },
- },
- minChecks: 1, // Summary check only
- },
- {
- name: "Listed on 2 RBLs",
- results: &RBLResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 2,
- Checks: []RBLCheck{
- {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
- {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
- {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
- },
- },
- minChecks: 3, // Summary + 2 listing checks
- },
- }
-
- checker := NewRBLChecker(5*time.Second, nil)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- checks := checker.GenerateRBLChecks(tt.results)
-
- if len(checks) < tt.minChecks {
- t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
- }
-
- // Verify all checks have the Blacklist category
- for _, check := range checks {
- if check.Category != api.Blacklist {
- t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
- }
- }
- })
- }
-}
-
func TestGetUniqueListedIPs(t *testing.T) {
- results := &RBLResults{
- Checks: []RBLCheck{
- {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
- {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
- {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
- {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
- {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
+ results := &DNSListResults{
+ Checks: map[string][]model.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 +367,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][]model.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 +406,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
index fe30c6c..26cd59d 100644
--- a/pkg/analyzer/report.go
+++ b/pkg/analyzer/report.go
@@ -24,7 +24,8 @@ package analyzer
import (
"time"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
@@ -32,37 +33,47 @@ import (
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
+ rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
- rblChecker *RBLChecker
+ rblChecker *DNSListChecker
+ dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer
- scorer *DeliverabilityScorer
+ headerAnalyzer *HeaderAnalyzer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
+ receiverHostname string,
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
+ dnswls []string,
+ checkAllIPs bool,
+ rspamdAPIURL string,
) *ReportGenerator {
return &ReportGenerator{
- authAnalyzer: NewAuthenticationAnalyzer(),
+ authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
spamAnalyzer: NewSpamAssassinAnalyzer(),
+ rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
- rblChecker: NewRBLChecker(dnsTimeout, rbls),
+ rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
+ dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
- scorer: NewDeliverabilityScorer(),
+ headerAnalyzer: NewHeaderAnalyzer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
- Authentication *api.AuthenticationResults
- SpamAssassin *SpamAssassinResult
- DNS *DNSResults
- RBL *RBLResults
+ Authentication *model.AuthenticationResults
Content *ContentResults
- Score *ScoringResult
+ DNS *model.DNSResults
+ Headers *model.HeaderAnalysis
+ RBL *DNSListResults
+ DNSWL *DNSListResults
+ SpamAssassin *model.SpamAssassinResult
+ Rspamd *model.RspamdResult
}
// AnalyzeEmail performs complete email analysis
@@ -73,248 +84,217 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
- results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
- results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
+ results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
+ results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, 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)
- // 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 {
+func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
reportID := uuid.New()
now := time.Now()
- report := &api.Report{
- Id: reportID,
- TestId: testID,
- Score: results.Score.OverallScore,
+ report := &model.Report{
+ Id: utils.UUIDToBase32(reportID),
+ TestId: utils.UUIDToBase32(testID),
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
+ // Calculate scores directly from analyzers (no more checks array)
+ dnsScore := 0
+ var dnsGrade string
if results.DNS != nil {
- dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
- checks = append(checks, dnsChecks...)
+ // 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)
}
- // RBL checks
- if results.RBL != nil {
- rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
- checks = append(checks, rblChecks...)
+ authScore := 0
+ var authGrade string
+ if results.Authentication != nil {
+ authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
}
- // SpamAssassin checks
- if results.SpamAssassin != nil {
- spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
- checks = append(checks, spamChecks...)
- }
-
- // Content checks
+ contentScore := 0
+ var contentGrade string
if results.Content != nil {
- contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
- checks = append(checks, contentChecks...)
+ contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
}
- // Header checks
- headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
- checks = append(checks, headerChecks...)
+ headerScore := 0
+ var headerGrade rune
+ if results.Headers != nil {
+ headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
+ }
- report.Checks = checks
+ blacklistScore := 0
+ var blacklistGrade string
+ var whitelistGrade string
+ if results.RBL != nil {
+ blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
+ _, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
+ }
+
+ 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 = &model.ScoreSummary{
+ DnsScore: dnsScore,
+ DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
+ AuthenticationScore: authScore,
+ AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
+ BlacklistScore: blacklistScore,
+ BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
+ ContentScore: contentScore,
+ ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
+ HeaderScore: headerScore,
+ HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
+ SpamScore: spamScore,
+ SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
+ }
// 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 content analysis
+ if results.Content != nil {
+ contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
+ report.ContentAnalysis = contentAnalysis
}
// Add DNS records
if results.DNS != nil {
- dnsRecords := r.buildDNSRecords(results.DNS)
- if len(dnsRecords) > 0 {
- report.DnsRecords = &dnsRecords
- }
+ report.DnsResults = results.DNS
}
- // Add blacklist checks
+ // 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 {
- 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
+ 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 := model.SpamAssassinResultDeliverabilityGrade(saGrade)
+ results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
+ results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
+ }
+ report.Spamassassin = results.SpamAssassin
+
+ // Add rspamd result with individual deliverability score
+ if results.Rspamd != nil {
+ rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
+ results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
+ results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
+ }
+ report.Rspamd = results.Rspamd
+
// Add raw headers
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 = model.ReportGrade(string([]byte{'A' + minusGrade}))
+ }
+ }
+
return report
}
-// buildDNSRecords converts DNS analysis results to API DNS records
-func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord {
- records := []api.DNSRecord{}
-
- if dns == nil {
- return records
- }
-
- // MX records
- if len(dns.MXRecords) > 0 {
- for _, mx := range dns.MXRecords {
- status := api.Found
- if !mx.Valid {
- if mx.Error != "" {
- status = api.Missing
- } else {
- status = api.Invalid
- }
- }
-
- record := api.DNSRecord{
- Domain: dns.Domain,
- RecordType: api.MX,
- Status: status,
- }
-
- if mx.Host != "" {
- value := mx.Host
- record.Value = &value
- }
-
- records = append(records, record)
- }
- }
-
- // SPF record
- if dns.SPFRecord != nil {
- status := api.Found
- if !dns.SPFRecord.Valid {
- if dns.SPFRecord.Record == "" {
- status = api.Missing
- } else {
- status = api.Invalid
- }
- }
-
- record := api.DNSRecord{
- Domain: dns.Domain,
- RecordType: api.SPF,
- Status: status,
- }
-
- if dns.SPFRecord.Record != "" {
- record.Value = &dns.SPFRecord.Record
- }
-
- records = append(records, record)
- }
-
- // DKIM records
- for _, dkim := range dns.DKIMRecords {
- status := api.Found
- if !dkim.Valid {
- if dkim.Record == "" {
- status = api.Missing
- } else {
- status = api.Invalid
- }
- }
-
- record := api.DNSRecord{
- Domain: dkim.Domain,
- RecordType: api.DKIM,
- Status: status,
- }
-
- if dkim.Record != "" {
- // Include selector in value for clarity
- value := dkim.Record
- record.Value = &value
- }
-
- records = append(records, record)
- }
-
- // DMARC record
- if dns.DMARCRecord != nil {
- status := api.Found
- if !dns.DMARCRecord.Valid {
- if dns.DMARCRecord.Record == "" {
- status = api.Missing
- } else {
- status = api.Invalid
- }
- }
-
- record := api.DNSRecord{
- Domain: dns.Domain,
- RecordType: api.DMARC,
- Status: status,
- }
-
- if dns.DMARCRecord.Record != "" {
- record.Value = &dns.DMARCRecord.Record
- }
-
- records = append(records, record)
- }
-
- return records
-}
-
// GenerateRawEmail returns the raw email message as a string
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
if email == nil {
@@ -328,21 +308,3 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
return raw
}
-
-// GetRecommendations returns actionable recommendations based on the score
-func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
- if results == nil || results.Score == nil {
- return []string{}
- }
-
- return results.Score.Recommendations
-}
-
-// GetScoreSummaryText returns a human-readable score summary
-func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
- if results == nil || results.Score == nil {
- return ""
- }
-
- return r.scorer.GetScoreSummary(results.Score)
-}
diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go
index 4a8fe00..5914737 100644
--- a/pkg/analyzer/report_test.go
+++ b/pkg/analyzer/report_test.go
@@ -24,16 +24,15 @@ package analyzer
import (
"net/mail"
"net/textproto"
- "strings"
"testing"
"time"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
func TestNewReportGenerator(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+ gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
@@ -53,13 +52,10 @@ func TestNewReportGenerator(t *testing.T) {
if gen.contentAnalyzer == nil {
t.Error("contentAnalyzer should not be nil")
}
- if gen.scorer == nil {
- t.Error("scorer should not be nil")
- }
}
func TestAnalyzeEmail(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+ gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
email := createTestEmail()
@@ -76,24 +72,10 @@ func TestAnalyzeEmail(t *testing.T) {
if results.Authentication == nil {
t.Error("Authentication should not be nil")
}
-
- // SpamAssassin might be nil if headers don't exist
- // DNS results should exist
- // RBL results should exist
- // Content results should exist
-
- if results.Score == nil {
- t.Error("Score should not be nil")
- }
-
- // Verify score is within bounds
- if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 {
- t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
- }
}
func TestGenerateReport(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+ gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
testID := uuid.New()
email := createTestEmail()
@@ -106,15 +88,17 @@ func TestGenerateReport(t *testing.T) {
}
// Verify required fields
- if report.Id == uuid.Nil {
+ if report.Id == "" {
t.Error("Report ID should not be empty")
}
- if report.TestId != testID {
- t.Errorf("TestId = %s, want %s", report.TestId, testID)
+ // 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 > 10 {
+ if report.Score < 0 || report.Score > 100 {
t.Errorf("Score %v is out of bounds", report.Score)
}
@@ -122,48 +106,31 @@ func TestGenerateReport(t *testing.T) {
t.Error("Summary should not be nil")
}
- if len(report.Checks) == 0 {
- t.Error("Checks should not be empty")
- }
-
- // Verify score summary
+ // Verify score summary (all scores are 0-100 percentages)
if report.Summary != nil {
- if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
+ 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 > 2 {
+ 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 > 2 {
+ 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 > 2 {
+ 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 > 1 {
+ if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 {
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)
+ 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)
+ gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
@@ -182,101 +149,8 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
}
}
-func TestBuildDNSRecords(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
-
- tests := []struct {
- name string
- dns *DNSResults
- expectedCount int
- expectTypes []api.DNSRecordRecordType
- }{
- {
- name: "Nil DNS results",
- dns: nil,
- expectedCount: 0,
- },
- {
- name: "Complete DNS results",
- dns: &DNSResults{
- Domain: "example.com",
- MXRecords: []MXRecord{
- {Host: "mail.example.com", Priority: 10, Valid: true},
- },
- SPFRecord: &SPFRecord{
- Record: "v=spf1 include:_spf.example.com -all",
- Valid: true,
- },
- DKIMRecords: []DKIMRecord{
- {
- Selector: "default",
- Domain: "example.com",
- Record: "v=DKIM1; k=rsa; p=...",
- Valid: true,
- },
- },
- DMARCRecord: &DMARCRecord{
- Record: "v=DMARC1; p=quarantine",
- Valid: true,
- },
- },
- expectedCount: 4, // MX, SPF, DKIM, DMARC
- expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC},
- },
- {
- name: "Missing records",
- dns: &DNSResults{
- Domain: "example.com",
- SPFRecord: &SPFRecord{
- Valid: false,
- Error: "No SPF record found",
- },
- },
- expectedCount: 1,
- expectTypes: []api.DNSRecordRecordType{api.SPF},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- records := gen.buildDNSRecords(tt.dns)
-
- if len(records) != tt.expectedCount {
- t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount)
- }
-
- // Verify expected types are present
- if tt.expectTypes != nil {
- foundTypes := make(map[api.DNSRecordRecordType]bool)
- for _, record := range records {
- foundTypes[record.RecordType] = true
- }
-
- for _, expectedType := range tt.expectTypes {
- if !foundTypes[expectedType] {
- t.Errorf("Expected DNS record type %s not found", expectedType)
- }
- }
- }
-
- // Verify all records have required fields
- for i, record := range records {
- if record.Domain == "" {
- t.Errorf("Record %d: Domain should not be empty", i)
- }
- if string(record.RecordType) == "" {
- t.Errorf("Record %d: RecordType should not be empty", i)
- }
- if string(record.Status) == "" {
- t.Errorf("Record %d: Status should not be empty", i)
- }
- }
- })
- }
-}
-
func TestGenerateRawEmail(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+ gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
tests := []struct {
name string
@@ -316,135 +190,6 @@ func TestGenerateRawEmail(t *testing.T) {
}
}
-func TestGetRecommendations(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
-
- tests := []struct {
- name string
- results *AnalysisResults
- expectCount int
- }{
- {
- name: "Nil results",
- results: nil,
- expectCount: 0,
- },
- {
- name: "Results with score",
- results: &AnalysisResults{
- Score: &ScoringResult{
- OverallScore: 5.0,
- Rating: "Fair",
- AuthScore: 1.5,
- SpamScore: 1.0,
- BlacklistScore: 1.5,
- ContentScore: 0.5,
- HeaderScore: 0.5,
- Recommendations: []string{
- "Improve authentication",
- "Fix content issues",
- },
- },
- },
- expectCount: 2,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- recs := gen.GetRecommendations(tt.results)
- if len(recs) != tt.expectCount {
- t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
- }
- })
- }
-}
-
-func TestGetScoreSummaryText(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
-
- tests := []struct {
- name string
- results *AnalysisResults
- expectEmpty bool
- expectString string
- }{
- {
- name: "Nil results",
- results: nil,
- expectEmpty: true,
- },
- {
- name: "Results with score",
- results: &AnalysisResults{
- Score: &ScoringResult{
- OverallScore: 8.5,
- Rating: "Good",
- AuthScore: 2.5,
- SpamScore: 1.8,
- BlacklistScore: 2.0,
- ContentScore: 1.5,
- HeaderScore: 0.7,
- CategoryBreakdown: map[string]CategoryScore{
- "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
- "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
- "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
- "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
- "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
- },
- },
- },
- expectEmpty: false,
- expectString: "8.5/10",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- summary := gen.GetScoreSummaryText(tt.results)
- if tt.expectEmpty {
- if summary != "" {
- t.Errorf("Expected empty summary, got %q", summary)
- }
- } else {
- if summary == "" {
- t.Error("Expected non-empty summary")
- }
- if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
- t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
- }
- }
- })
- }
-}
-
-func TestReportCategories(t *testing.T) {
- gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
- testID := uuid.New()
-
- email := createComprehensiveTestEmail()
- results := gen.AnalyzeEmail(email)
- report := gen.GenerateReport(testID, results)
-
- // Verify all check categories are present
- categories := make(map[api.CheckCategory]bool)
- for _, check := range report.Checks {
- categories[check.Category] = true
- }
-
- expectedCategories := []api.CheckCategory{
- api.Authentication,
- api.Dns,
- api.Headers,
- }
-
- for _, cat := range expectedCategories {
- if !categories[cat] {
- t.Errorf("Expected category %s not found in checks", cat)
- }
- }
-}
-
// Helper functions
func createTestEmail() *EmailMessage {
@@ -481,21 +226,3 @@ func createTestEmailWithSpamAssassin() *EmailMessage {
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
return email
}
-
-func createComprehensiveTestEmail() *EmailMessage {
- email := createTestEmailWithSpamAssassin()
-
- // Add authentication headers
- email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
- "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
- }
-
- // Add HTML content
- email.Parts = append(email.Parts, MessagePart{
- ContentType: "text/html",
- Content: "Test
Link",
- IsHTML: true,
- })
-
- return email
-}
diff --git a/pkg/analyzer/rspamd-symbols-README.md b/pkg/analyzer/rspamd-symbols-README.md
new file mode 100644
index 0000000..882eab2
--- /dev/null
+++ b/pkg/analyzer/rspamd-symbols-README.md
@@ -0,0 +1,21 @@
+# rspamd-symbols.json
+
+This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
+
+## How to update
+
+Fetch the latest symbols from a running rspamd instance:
+
+```sh
+curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
+```
+
+Or with docker:
+
+```sh
+docker run --rm --name rspamd --pull always rspamd/rspamd
+docker exec -u 0 rspamd apt install -y curl
+docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
+```
+
+Then rebuild the project.
diff --git a/pkg/analyzer/rspamd-symbols.json b/pkg/analyzer/rspamd-symbols.json
new file mode 100644
index 0000000..5538985
--- /dev/null
+++ b/pkg/analyzer/rspamd-symbols.json
@@ -0,0 +1,6646 @@
+[
+ {
+ "group": "arc",
+ "rules": [
+ {
+ "symbol": "ARC_ALLOW",
+ "weight": -1.0,
+ "description": "ARC checks success",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_REJECT",
+ "weight": 1.0,
+ "description": "ARC checks failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_NA",
+ "weight": 0.0,
+ "description": "ARC signature absent",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_INVALID",
+ "weight": 0.500000,
+ "description": "ARC structure invalid",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_DNSFAIL",
+ "weight": 0.0,
+ "description": "ARC DNS error",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_SIGNED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "rbl",
+ "rules": [
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT",
+ "weight": 1.500000,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_0",
+ "weight": 4.0,
+ "description": "SenderScore Reputation: Very Bad (0-9).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_2",
+ "weight": 3.0,
+ "description": "SenderScore Reputation: Bad (20-29).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_RED",
+ "weight": 0.500000,
+ "description": "A domain in the message is listed in URIBL.com red",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST_NA",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - pristine+noauth"
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_CSS",
+ "weight": 1.0,
+ "description": "Received address is listed in Spamhaus CSS",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_BLOCKED",
+ "weight": 0.0,
+ "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from SenderScore RPBL"
+ },
+ {
+ "symbol": "RBL_VIRUSFREE_BOTNET",
+ "weight": 2.0,
+ "description": "From address is listed in virusfree.cz BL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_HI",
+ "weight": -3.500000,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_VIRUSFREE_UNKNOWN",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_MAILSPIKE_BAD",
+ "weight": 1.0,
+ "description": "From address is listed in Mailspike RBL - bad reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_SBL",
+ "weight": 4.0,
+ "description": "From address is listed in Spamhaus SBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_BLOCKLISTDE",
+ "weight": 3.0,
+ "description": "Received address is listed in Blocklist (https://www.blocklist.de/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CRACKED_SURBL",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in SURBL as cracked",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_HASHBL_CRACKED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_BLOCKED",
+ "weight": 0.0,
+ "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_4",
+ "weight": 2.0,
+ "description": "SenderScore Reputation: Bad (40-49).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PH_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT",
+ "weight": 3.500000,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT",
+ "weight": 1.0,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_8",
+ "weight": 0.0,
+ "description": "SenderScore Reputation: Neutral (80-89).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_MED",
+ "weight": -0.200000,
+ "description": "Sender listed at https://www.dnswl.org, medium trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_NONE",
+ "weight": 0.0,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MSBL_EBL",
+ "weight": 7.500000,
+ "description": "MSBL emailbl (https://www.msbl.org/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_XBL",
+ "weight": 4.0,
+ "description": "From address is listed in Spamhaus XBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_NA",
+ "weight": 1.0,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST_BOT",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - pristine+botnet"
+ },
+ {
+ "symbol": "SURBL_HASHBL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST_NA_BOT",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet"
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_SBL",
+ "weight": 3.0,
+ "description": "Received address is listed in Spamhaus SBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_POSSIBLE",
+ "weight": 0.0,
+ "description": "From address is listed in Mailspike RWL - possibly legit",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_HI",
+ "weight": -0.500000,
+ "description": "Sender listed at https://www.dnswl.org, high trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_PBL",
+ "weight": 2.0,
+ "description": "From address is listed in Spamhaus PBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_LOW",
+ "weight": -1.0,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED",
+ "weight": 0.0,
+ "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_7",
+ "weight": 0.500000,
+ "description": "SenderScore Reputation: Bad (70-79).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_FRESH15_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_HASHBL_MALWARE",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_MALWARE",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_BLOCKLISTDE",
+ "weight": 4.0,
+ "description": "From address is listed in Blocklist (https://www.blocklist.de/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_SPAM",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ABUSE_SURBL",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in SURBL as abused",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_MALWARE",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_HASHBL_PHISH",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_DROP",
+ "weight": 6.0,
+ "description": "Received address is listed in Spamhaus DROP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_NA",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+noauth"
+ },
+ {
+ "symbol": "DBL_ABUSE_REDIR",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CT_SURBL",
+ "weight": 0.0,
+ "description": "A domain in the message is listed in SURBL as a clicktracker",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_HASHBL_EMAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth"
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_XBL",
+ "weight": 1.0,
+ "description": "Received address is listed in Spamhaus XBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_GOOD",
+ "weight": -0.100000,
+ "description": "From address is listed in Mailspike RWL - good reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_PRST",
+ "weight": 4.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+pristine"
+ },
+ {
+ "symbol": "RBL_MAILSPIKE_VERYBAD",
+ "weight": 1.500000,
+ "description": "From address is listed in Mailspike RBL - very bad reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SEM_IPV6",
+ "weight": 1.0,
+ "description": "From address is listed in Spameatingmonkey RBL (IPv6)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MW_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_NA",
+ "weight": 0.0,
+ "description": "From address is listed in SenderScore RPBL - noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_9",
+ "weight": -1.0,
+ "description": "SenderScore Reputation: Good (90-100).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_BLOCKED",
+ "weight": 0.0,
+ "description": "URIBL.com: query refused, likely due to policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_GREY",
+ "weight": 2.500000,
+ "description": "A domain in the message is listed in URIBL.com grey",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_BLOCKED",
+ "weight": 0.0,
+ "description": "SURBL: query blocked by policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_LOW",
+ "weight": -0.100000,
+ "description": "Sender listed at https://www.dnswl.org, low trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_PHISH",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_NONE",
+ "weight": 0.0,
+ "description": "Sender listed at https://www.dnswl.org, no trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA",
+ "weight": 4.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth"
+ },
+ {
+ "symbol": "MSBL_EBL_GREY",
+ "weight": 0.500000,
+ "description": "MSBL emailbl grey list (https://www.msbl.org/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_1",
+ "weight": 3.500000,
+ "description": "SenderScore Reputation: Bad (10-19).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_BOT",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - botnet"
+ },
+ {
+ "symbol": "SEM_URIBL_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spameatingmonkey URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_NEUTRAL",
+ "weight": 0.0,
+ "description": "Neutral result from Mailspike",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_HASHBL_ABUSE",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_6",
+ "weight": 1.0,
+ "description": "SenderScore Reputation: Bad (60-69).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL",
+ "weight": 3.500000,
+ "description": "A domain in the message is listed in Spameatingmonkey URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_PBL",
+ "weight": 0.0,
+ "description": "Received address is listed in Spamhaus PBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DM_SURBL",
+ "weight": 0.0,
+ "description": "A domain in the message is listed in SURBL as belonging to a disposable email service",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_5",
+ "weight": 1.500000,
+ "description": "SenderScore Reputation: Bad (50-59).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_MAILSPIKE_WORST",
+ "weight": 2.0,
+ "description": "From address is listed in Mailspike RBL - worst possible reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_BOTNET",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth"
+ },
+ {
+ "symbol": "DWL_DNSWL",
+ "weight": 0.0,
+ "description": "Unrecognised result from https://www.dnswl.org (DWL)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_CSS",
+ "weight": 2.0,
+ "description": "From address is listed in Spamhaus CSS",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - pristine"
+ },
+ {
+ "symbol": "DWL_DNSWL_MED",
+ "weight": -2.0,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_DROP",
+ "weight": 7.0,
+ "description": "From address is listed in Spamhaus DROP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognized result from SenderScore Reputation list.",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus DBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MAILSPIKE",
+ "weight": 0.0,
+ "description": "Unrecognised result from Mailspike",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score"
+ },
+ {
+ "symbol": "RBL_SPAMHAUS",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus ZEN",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DNSWL_BLOCKED",
+ "weight": 0.0,
+ "description": "https://www.dnswl.org: Resolver blocked due to excessive queries",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL",
+ "weight": 0.0,
+ "description": "Unrecognised result from https://www.dnswl.org",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_VERYGOOD",
+ "weight": -0.200000,
+ "description": "From address is listed in Mailspike RWL - very good reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_3",
+ "weight": 2.500000,
+ "description": "SenderScore Reputation: Bad (30-39).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_MULTI",
+ "weight": 0.0,
+ "description": "Unrecognised result from URIBL.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_FRESH15",
+ "weight": 3.0,
+ "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SEM",
+ "weight": 1.0,
+ "description": "From address is listed in Spameatingmonkey RBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_EXCELLENT",
+ "weight": -0.400000,
+ "description": "From address is listed in Mailspike RWL - excellent reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_EMAILBL",
+ "weight": 2.500000,
+ "description": "Rspamd emailbl, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_BLACK",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in URIBL.com black",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_URIBL",
+ "weight": 4.500000,
+ "description": "Rspamd uribl, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_MULTI",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_NA_BOT",
+ "weight": 1.0,
+ "description": "From address is listed in SenderScore RPBL - noauth+botnet"
+ },
+ {
+ "symbol": "DBL_PROHIBIT",
+ "weight": 0.0,
+ "description": "DBL uribl IP queries prohibited!",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BOTNET",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_PHISH",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "dnswl",
+ "rules": [
+ {
+ "symbol": "RCVD_IN_DNSWL_MED",
+ "weight": -0.200000,
+ "description": "Sender listed at https://www.dnswl.org, medium trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_LOW",
+ "weight": -0.100000,
+ "description": "Sender listed at https://www.dnswl.org, low trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_NONE",
+ "weight": 0.0,
+ "description": "Sender listed at https://www.dnswl.org, no trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL",
+ "weight": 0.0,
+ "description": "Unrecognised result from https://www.dnswl.org (DWL)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL",
+ "weight": 0.0,
+ "description": "Unrecognised result from https://www.dnswl.org",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DNSWL_BLOCKED",
+ "weight": 0.0,
+ "description": "https://www.dnswl.org: Resolver blocked due to excessive queries",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_BLOCKED",
+ "weight": 0.0,
+ "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_HI",
+ "weight": -0.500000,
+ "description": "Sender listed at https://www.dnswl.org, high trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_LOW",
+ "weight": -1.0,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_NONE",
+ "weight": 0.0,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_HI",
+ "weight": -3.500000,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_MED",
+ "weight": -2.0,
+ "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "dmarc",
+ "rules": [
+ {
+ "symbol": "DMARC_POLICY_ALLOW",
+ "weight": -0.500000,
+ "description": "DMARC permit policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_DMARC",
+ "weight": 6.0,
+ "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_REJECT",
+ "weight": 2.0,
+ "description": "DMARC reject policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES",
+ "weight": -0.500000,
+ "description": "DMARC permit policy with DKIM/SPF failure",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_SOFTFAIL",
+ "weight": 0.100000,
+ "description": "DMARC failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_DMARC",
+ "weight": -7.0,
+ "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_NA",
+ "weight": 0.0,
+ "description": "No DMARC record",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_QUARANTINE",
+ "weight": 1.500000,
+ "description": "DMARC quarantine policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_DNSFAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_BAD_POLICY",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "statistics",
+ "rules": [
+ {
+ "symbol": "BAYES_SPAM",
+ "weight": 5.100000,
+ "description": "Message probably spam, probability: ",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BAYES_HAM",
+ "weight": -3.0,
+ "description": "Message probably ham, probability: ",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "dkim",
+ "rules": [
+ {
+ "symbol": "R_DKIM_ALLOW",
+ "weight": -0.200000,
+ "description": "DKIM verification succeed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_DKIM",
+ "weight": -1.0,
+ "description": "Mail comes from the whitelisted domain and has a valid DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_REJECT",
+ "weight": 1.0,
+ "description": "DKIM verification failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_SPF_DKIM",
+ "weight": -3.0,
+ "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_DMARC",
+ "weight": 6.0,
+ "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_TEMPFAIL",
+ "weight": 0.0,
+ "description": "DKIM verification soft-failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_CHECK",
+ "weight": 0.0,
+ "description": "DKIM check callback",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_DKIM",
+ "weight": 2.0,
+ "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_PERMFAIL",
+ "weight": 0.0,
+ "description": "DKIM verification hard-failed (invalid)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_SPF_DKIM",
+ "weight": 3.0,
+ "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_NA",
+ "weight": 0.0,
+ "description": "Missing DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_SIGNED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_TRACE",
+ "weight": 0.0,
+ "description": "DKIM trace symbol",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_DMARC",
+ "weight": -7.0,
+ "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "sem",
+ "rules": [
+ {
+ "symbol": "SEM_URIBL_FRESH15_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_FRESH15",
+ "weight": 3.0,
+ "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL",
+ "weight": 3.500000,
+ "description": "A domain in the message is listed in Spameatingmonkey URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SEM",
+ "weight": 1.0,
+ "description": "From address is listed in Spameatingmonkey RBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SEM_IPV6",
+ "weight": 1.0,
+ "description": "From address is listed in Spameatingmonkey RBL (IPv6)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spameatingmonkey URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "neural",
+ "rules": []
+ },
+ {
+ "group": "policies",
+ "rules": [
+ {
+ "symbol": "R_SPF_NA",
+ "weight": 0.0,
+ "description": "Missing SPF record",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_TEMPFAIL",
+ "weight": 0.0,
+ "description": "DKIM verification soft-failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_SOFTFAIL",
+ "weight": 0.100000,
+ "description": "DMARC failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_ALLOW",
+ "weight": -1.0,
+ "description": "ARC checks success",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_SIGNED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_ALLOW",
+ "weight": -0.200000,
+ "description": "SPF verification allows sending",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_NA",
+ "weight": 0.0,
+ "description": "Missing DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_BAD_POLICY",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPF_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_NA",
+ "weight": 0.0,
+ "description": "No DMARC record",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES",
+ "weight": -0.500000,
+ "description": "DMARC permit policy with DKIM/SPF failure",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_PLUSALL",
+ "weight": 4.0,
+ "description": "SPF record allows to send from any IP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_SOFTFAIL",
+ "weight": 0.0,
+ "description": "SPF verification soft-failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_INVALID",
+ "weight": 0.500000,
+ "description": "ARC structure invalid",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_DNSFAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_PERMFAIL",
+ "weight": 0.0,
+ "description": "DKIM verification hard-failed (invalid)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_TRACE",
+ "weight": 0.0,
+ "description": "DKIM trace symbol",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_ALLOW",
+ "weight": -0.500000,
+ "description": "DMARC permit policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_CHECK",
+ "weight": 0.0,
+ "description": "DKIM check callback",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_DNSFAIL",
+ "weight": 0.0,
+ "description": "ARC DNS error",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_REJECT",
+ "weight": 1.0,
+ "description": "ARC checks failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_PERMFAIL",
+ "weight": 0.0,
+ "description": "SPF record is malformed or persistent DNS error",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_NA",
+ "weight": 0.0,
+ "description": "ARC signature absent",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_NEUTRAL",
+ "weight": 0.0,
+ "description": "SPF policy is neutral",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_QUARANTINE",
+ "weight": 1.500000,
+ "description": "DMARC quarantine policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_FAIL",
+ "weight": 1.0,
+ "description": "SPF verification failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_DNSFAIL",
+ "weight": 0.0,
+ "description": "SPF DNS failure",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_REJECT",
+ "weight": 2.0,
+ "description": "DMARC reject policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_ALLOW",
+ "weight": -0.200000,
+ "description": "DKIM verification succeed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_DKIM_REJECT",
+ "weight": 1.0,
+ "description": "DKIM verification failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_SIGNED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ARC_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "surbl",
+ "rules": [
+ {
+ "symbol": "DBL_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_BOTNET",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_PROHIBIT",
+ "weight": 0.0,
+ "description": "DBL uribl IP queries prohibited!",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPAMHAUS_ZEN_URIBL",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus ZEN URIBL"
+ },
+ {
+ "symbol": "MSBL_EBL",
+ "weight": 7.500000,
+ "description": "MSBL emailbl (https://www.msbl.org/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PH_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BOTNET",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_EMAILBL",
+ "weight": 2.500000,
+ "description": "Rspamd emailbl, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spameatingmonkey URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CT_SURBL",
+ "weight": 0.0,
+ "description": "A domain in the message is listed in SURBL as a clicktracker",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL",
+ "weight": 3.500000,
+ "description": "A domain in the message is listed in Spameatingmonkey URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_URIBL",
+ "weight": 4.500000,
+ "description": "Rspamd uribl, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_FRESH15_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_SBL",
+ "weight": 6.500000,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL"
+ },
+ {
+ "symbol": "URIBL_BLACK",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in URIBL.com black",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ABUSE_SURBL",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in SURBL as abused",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_REDIR",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_PBL",
+ "weight": 0.010000,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL"
+ },
+ {
+ "symbol": "DBL_ABUSE_PHISH",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MSBL_EBL_GREY",
+ "weight": 0.500000,
+ "description": "MSBL emailbl grey list (https://www.msbl.org/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_SPAM",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CRACKED_SURBL",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in SURBL as cracked",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_GREY",
+ "weight": 2.500000,
+ "description": "A domain in the message is listed in URIBL.com grey",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_RED",
+ "weight": 0.500000,
+ "description": "A domain in the message is listed in URIBL.com red",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_DROP",
+ "weight": 5.0,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP"
+ },
+ {
+ "symbol": "DBL_PHISH",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_MULTI",
+ "weight": 0.0,
+ "description": "Unrecognised result from URIBL.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_MALWARE",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus DBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_MALWARE",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MW_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_XBL",
+ "weight": 3.0,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL"
+ },
+ {
+ "symbol": "SEM_URIBL_FRESH15",
+ "weight": 3.0,
+ "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_SBL_CSS",
+ "weight": 5.0,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS"
+ },
+ {
+ "symbol": "DM_SURBL",
+ "weight": 0.0,
+ "description": "A domain in the message is listed in SURBL as belonging to a disposable email service",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_BLOCKED",
+ "weight": 0.0,
+ "description": "URIBL.com: query refused, likely due to policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_BLOCKED",
+ "weight": 0.0,
+ "description": "SURBL: query blocked by policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "mime",
+ "rules": [
+ {
+ "symbol": "MIME_BASE64_TEXT_BOGUS",
+ "weight": 1.0,
+ "description": "Has text part encoded in base64 that does not contain any 8bit characters",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CTYPE_MIXED_BOGUS",
+ "weight": 1.0,
+ "description": "multipart/mixed without non-textual part",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CTYPE_MISSING_DISPOSITION",
+ "weight": 4.0,
+ "description": "Binary content-type not specified as an attachment",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BASE64_TEXT",
+ "weight": 0.100000,
+ "description": "Has text part encoded in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "multimap",
+ "rules": [
+ {
+ "symbol": "DISPOSABLE_FROM",
+ "weight": 0.0,
+ "description": "From a Disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DISPOSABLE_ENVFROM",
+ "weight": 0.0,
+ "description": "Envelope From is a Disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DISPOSABLE_TO",
+ "weight": 0.0,
+ "description": "To a disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DISPOSABLE_REPLYTO",
+ "weight": 0.0,
+ "description": "Reply-To a disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DISPOSABLE_CC",
+ "weight": 0.0,
+ "description": "To a disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_TO",
+ "weight": 0.0,
+ "description": "To is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_ENVRCPT",
+ "weight": 0.0,
+ "description": "Envelope Recipient is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_ENVFROM",
+ "weight": 0.0,
+ "description": "Envelope From is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DISPOSABLE_MDN",
+ "weight": 0.500000,
+ "description": "Disposition-Notification-To is a disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_MDN",
+ "weight": 0.0,
+ "description": "Disposition-Notification-To is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_FROM",
+ "weight": 0.0,
+ "description": "From is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_REPLYTO",
+ "weight": 0.0,
+ "description": "Reply-To is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DISPOSABLE_ENVRCPT",
+ "weight": 0.0,
+ "description": "Envelope Recipient is a Disposable e-mail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_CC",
+ "weight": 0.0,
+ "description": "To is a Freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REDIRECTOR_URL",
+ "weight": 0.0,
+ "description": "The presence of a redirector in the mail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "excessqp",
+ "rules": [
+ {
+ "symbol": "CC_EXCESS_QP",
+ "weight": 1.200000,
+ "description": "Cc header is unnecessarily encoded in quoted-printable",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJ_EXCESS_QP",
+ "weight": 1.200000,
+ "description": "Subject header is unnecessarily encoded in quoted-printable",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_EXCESS_QP",
+ "weight": 1.200000,
+ "description": "Reply-To header is unnecessarily encoded in quoted-printable",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_EXCESS_QP",
+ "weight": 1.200000,
+ "description": "From header is unnecessarily encoded in quoted-printable",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_EXCESS_QP",
+ "weight": 1.200000,
+ "description": "To header is unnecessarily encoded in quoted-printable",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "upstream_spam_filters",
+ "rules": [
+ {
+ "symbol": "UNITEDINTERNET_SPAM",
+ "weight": 5.0,
+ "description": "United Internet says this message is spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "KLMS_SPAM",
+ "weight": 5.0,
+ "description": "Kaspersky Security for Mail Server says this message is spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MICROSOFT_SPAM",
+ "weight": 4.0,
+ "description": "Microsoft says the message is spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PRECEDENCE_BULK",
+ "weight": 0.0,
+ "description": "Message marked as bulk",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPAM_FLAG",
+ "weight": 5.0,
+ "description": "Message was already marked as spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "headers",
+ "rules": [
+ {
+ "symbol": "FAKE_RECEIVED_smtp_yandex_ru",
+ "weight": 4.0,
+ "description": "Fake smtp.yandex.ru Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_RCONFIRM_MISMATCH",
+ "weight": 2.0,
+ "description": "Read confirmation address is different to from address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_ZERO",
+ "weight": 0.0,
+ "description": "No recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MAILER_1C_8",
+ "weight": 0.0,
+ "description": "Sent with 1C:Enterprise 8",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPTO_QUOTE_YAHOO",
+ "weight": 2.0,
+ "description": "Quoted Reply-To header from Yahoo (seems to be forged)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_SEVEN",
+ "weight": 0.0,
+ "description": "Message has 7-11 Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_ZERO",
+ "weight": 0.0,
+ "description": "Message has no Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPOOF_DISPLAY_NAME",
+ "weight": 8.0,
+ "description": "Display name is being used to spoof and trick the recipient",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DN_EQ_ADDR_ALL",
+ "weight": 0.0,
+ "description": "All of the recipients have display names that are the same as their address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CHECK_FROM",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_ENDS_EXCLAIM",
+ "weight": 0.0,
+ "description": "Subject ends with an exclamation mark",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_IMS",
+ "weight": 3.0,
+ "description": "Forged X-Mailer: Internet Mail Service",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_SENDER",
+ "weight": 0.300000,
+ "description": "Sender is forged (different From: header and smtp MAIL FROM: addresses)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_ONE",
+ "weight": 0.0,
+ "description": "Message has one Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INVALID_RCPT_8BIT",
+ "weight": 6.0,
+ "description": "Invalid 8bit character in recipients headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_THEBAT_BOUN",
+ "weight": 2.0,
+ "description": "Forged The Bat! MUA headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MAIL_RU_MAILER",
+ "weight": 0.0,
+ "description": "Sent with Mail.Ru webmail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_CC_EMPTY_DELIMITER",
+ "weight": 1.0,
+ "description": "Cc header has no delimiter between header name and header value",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "OLD_X_MAILER",
+ "weight": 2.0,
+ "description": "X-Mailer header has a very old MUA version",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_GENERIC_RECEIVED4",
+ "weight": 3.600000,
+ "description": "Forged generic Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FAKE_REPLY",
+ "weight": 1.0,
+ "description": "Fake reply",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "STRONGMAIL",
+ "weight": 6.0,
+ "description": "Sent via rogue \"strongmail\" MTA",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PRIO_FIVE",
+ "weight": 0.0,
+ "description": "Message has X-Priority header set to 5 or higher",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_MIME_VERSION",
+ "weight": 2.0,
+ "description": "MIME-Version header is missing in MIME message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CHECK_RCVD",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_DOUBLE_IP_SPAM",
+ "weight": 2.0,
+ "description": "Has two Received headers containing bare IP addresses",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_REPLYTO",
+ "weight": 0.0,
+ "description": "Has Reply-To header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_MA_MISSING_HTML",
+ "weight": 1.0,
+ "description": "MIME multipart/alternative missing text/html part",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_DN_EQ_FROM_DN",
+ "weight": 0.0,
+ "description": "Reply-To display name matches From",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_DOM_EQ_TO_DOM",
+ "weight": 0.0,
+ "description": "Reply-To domain matches the To domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "X_PHPOS_FAKE",
+ "weight": 3.0,
+ "description": "Fake X-PHP-Originating-Script header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ENVFROM_VERP",
+ "weight": 0.0,
+ "description": "Envelope From is a VERP address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_EQ_ENVFROM",
+ "weight": 0.0,
+ "description": "From address is the same as the envelope",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_ORG_HEADER",
+ "weight": 0.0,
+ "description": "Has Organization header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_TO",
+ "weight": 2.0,
+ "description": "To header is missing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BROKEN_HEADERS",
+ "weight": 10.0,
+ "description": "Headers structure is likely broken",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_DN_EQ_ADDR",
+ "weight": 1.0,
+ "description": "From header display name is the same as the address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_REPLYTO_NEQ_FROM_DOM",
+ "weight": 3.0,
+ "description": "The From and Reply-To addresses in the email are from different freemail services",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_HELO_LOCALHOST",
+ "weight": 0.0,
+ "description": "Localhost HELO seen in Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_BAD_CTE_7BIT",
+ "weight": 3.500000,
+ "description": "Detects bad Content-Transfer-Encoding for text parts",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_FROM_EMPTY_DELIMITER",
+ "weight": 1.0,
+ "description": "From header has no delimiter between header name and header value",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_HAS_QUESTION",
+ "weight": 0.0,
+ "description": "Subject contains a question mark",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PRIO_ZERO",
+ "weight": 0.0,
+ "description": "Message has X-Priority header set to 0",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DN_SOME",
+ "weight": 0.0,
+ "description": "Some of the recipients have display names",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ONCE_RECEIVED",
+ "weight": 0.100000,
+ "description": "One received header in a message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INFO_TO_INFO_LU",
+ "weight": 2.0,
+ "description": "info@ From/To address with List-Unsubscribe headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_DOM_EQ_FROM_DOM",
+ "weight": 0.0,
+ "description": "Reply-To domain matches the From domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_MA_MISSING_TEXT",
+ "weight": 2.0,
+ "description": "MIME multipart/alternative missing text/plain part",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_TWO",
+ "weight": 0.0,
+ "description": "Two recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_THREE",
+ "weight": 0.0,
+ "description": "3-5 recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PRIO",
+ "weight": 0.0,
+ "description": "X-Priority check callback rule",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DN_NONE",
+ "weight": 0.0,
+ "description": "None of the recipients have display names",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_TWO",
+ "weight": 0.0,
+ "description": "Message has two Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CTE_CASE",
+ "weight": 0.500000,
+ "description": "[78]Bit .vs. [78]bit",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_HAS_EXCLAIM",
+ "weight": 0.0,
+ "description": "Subject contains an exclamation mark",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_XM_UA",
+ "weight": 0.0,
+ "description": "Message has neither X-Mailer nor User-Agent header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "X_PHP_FORGED_0X",
+ "weight": 4.0,
+ "description": "X-PHP-Originating-Script header appears forged",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "APPLE_IOS_MAILER",
+ "weight": 0.0,
+ "description": "Sent with Apple iPhone/iPad Mail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_LIST_UNSUB",
+ "weight": -0.010000,
+ "description": "Has List-Unsubscribe header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ENVFROM_INVALID",
+ "weight": 2.0,
+ "description": "Envelope from does not have a valid format",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_GENERIC_RECEIVED3",
+ "weight": 3.600000,
+ "description": "Forged generic Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_MIXED_CHARSET",
+ "weight": 5.0,
+ "description": "Mixed characters in a message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INVALID_MSGID",
+ "weight": 1.700000,
+ "description": "Message-ID header is incorrect",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_DOM_NEQ_FROM_DOM",
+ "weight": 0.0,
+ "description": "Reply-To domain does not match the From domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_ENDS_SPACES",
+ "weight": 0.500000,
+ "description": "Subject ends with space characters",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_TWELVE",
+ "weight": 0.0,
+ "description": "Message has 12 or more Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_NEQ_DISPLAY_NAME",
+ "weight": 4.0,
+ "description": "Display name contains an email address different to the From address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BROKEN_CONTENT_TYPE",
+ "weight": 1.500000,
+ "description": "Message has part with broken content type",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_DATE",
+ "weight": 1.0,
+ "description": "Date header is missing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MSGID_YAHOO",
+ "weight": 2.0,
+ "description": "Forged Yahoo Message-ID header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DN_EQ_ADDR_SOME",
+ "weight": 0.0,
+ "description": "Some of the recipients have display names that are the same as their address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_RCVD_SPAMBOTS",
+ "weight": 3.0,
+ "description": "Spambots signatures in received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_MISSING_CHARSET",
+ "weight": 0.500000,
+ "description": "Charset header is missing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_MID",
+ "weight": 2.500000,
+ "description": "Message-ID header is missing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_FORGED_MDN",
+ "weight": 2.0,
+ "description": "Read confirmation address is different to return path",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPOOF_REPLYTO",
+ "weight": 6.0,
+ "description": "Reply-To is being used to spoof and trick the recipient to send an off-domain reply",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_DATE_EMPTY_DELIMITER",
+ "weight": 1.0,
+ "description": "Date header has no delimiter between header name and header value",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_MATCH_ENVRCPT_SOME",
+ "weight": 0.0,
+ "description": "Some of the recipients match the envelope",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_RECIPIENTS_MAILLIST",
+ "weight": 0.0,
+ "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_FROM",
+ "weight": 2.0,
+ "description": "Missing From header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_SEVEN",
+ "weight": 0.0,
+ "description": "7-11 recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_UNPARSEABLE",
+ "weight": 1.0,
+ "description": "Reply-To header could not be parsed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PRIO_ONE",
+ "weight": 0.0,
+ "description": "Message has X-Priority header set to 1",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_GT_50",
+ "weight": 0.0,
+ "description": "50+ recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_TLS_LAST",
+ "weight": 0.0,
+ "description": "Last hop used encrypted transports",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_NAME_HAS_TITLE",
+ "weight": 1.0,
+ "description": "From header display name has a title (Mr/Mrs/Dr)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PREVIOUSLY_DELIVERED",
+ "weight": 0.0,
+ "description": "Message either to a list or was forwarded",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_HELO_USER",
+ "weight": 3.0,
+ "description": "HELO User spam pattern",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_X_MAILER",
+ "weight": 4.500000,
+ "description": "Forged X-Mailer header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_HTTP_URL_IN_FROM",
+ "weight": 5.0,
+ "description": "HTTP URL preceded by the start of a line, quote, or whitespace, with normal or URL-encoded colons in From header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DOM_EQ_FROM_DOM",
+ "weight": 0.0,
+ "description": "To domain is the same as the From domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_TWELVE",
+ "weight": 0.0,
+ "description": "12-50 recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_OUTLOOK_TAGS",
+ "weight": 2.100000,
+ "description": "Message pretends to be send from Outlook but has 'strange' tags",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_NO_DN",
+ "weight": 0.0,
+ "description": "From header does not have a display name",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INVALID_DATE",
+ "weight": 1.500000,
+ "description": "Malformed Date header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_NO_SPACE_IN_FROM",
+ "weight": 1.0,
+ "description": "No space in From header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_OUTLOOK_HTML",
+ "weight": 5.0,
+ "description": "Forged Outlook HTML signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_DISPLAY_CALLBACK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_ADDR_EQ_FROM",
+ "weight": 0.0,
+ "description": "Reply-To header is identical to SMTP From",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_SENDER_MAILLIST",
+ "weight": 0.0,
+ "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_WRAPPED_IN_SPACES",
+ "weight": 2.0,
+ "description": "To address is wrapped in spaces inside angle brackets (e.g. display-name < local-part@domain >)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DIRECT_TO_MX",
+ "weight": 0.0,
+ "description": "Message has been directly delivered from MUA to local MX",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_FIVE",
+ "weight": 0.0,
+ "description": "Message has 5-7 Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_GENERIC_RECEIVED",
+ "weight": 3.600000,
+ "description": "Forged generic Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_ENDS_QUESTION",
+ "weight": 1.0,
+ "description": "Subject ends with a question mark",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_CALLBACK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_RECIPIENTS",
+ "weight": 2.0,
+ "description": "Recipients are not the same as RCPT TO: mail command",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TRACKER_ID",
+ "weight": 3.840000,
+ "description": "Spam string at the end of message to make statistics fault",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_NEQ_ENVFROM",
+ "weight": 0.0,
+ "description": "From address is different to the envelope",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CT_EXTRA_SEMI",
+ "weight": 1.0,
+ "description": "Content-Type header ends with a semi-colon",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MAILLIST",
+ "weight": -0.200000,
+ "description": "Message seems to be from maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PRIO_TWO",
+ "weight": 0.0,
+ "description": "Message has X-Priority header set to 2",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_FIVE",
+ "weight": 0.0,
+ "description": "5-7 recipients",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_SUBJECT",
+ "weight": 2.0,
+ "description": "Subject header is missing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CD_MM_BODY",
+ "weight": 2.0,
+ "description": "Content-Description header reads \"Mail message body\", commonly seen in spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "YANDEX_RU_MAILER",
+ "weight": 0.0,
+ "description": "Sent with Yandex webmail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "GOOGLE_FORWARDING_MID_MISSING",
+ "weight": 2.500000,
+ "description": "Message was missing Message-ID pre-forwarding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_NEEDS_ENCODING",
+ "weight": 1.0,
+ "description": "To header needs encoding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_NEEDS_ENCODING",
+ "weight": 1.0,
+ "description": "From header needs encoding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_NEEDS_ENCODING",
+ "weight": 1.0,
+ "description": "Subject needs encoding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_EQ_TO_ADDR",
+ "weight": 5.0,
+ "description": "Reply-To is the same as the To address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_EMAIL_HAS_TITLE",
+ "weight": 2.0,
+ "description": "Reply-To header has title",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCPT_COUNT_ONE",
+ "weight": 0.0,
+ "description": "One recipient",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_EQ_FROM",
+ "weight": 0.0,
+ "description": "To address matches the From address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CHECK_MIME",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_RECIPS",
+ "weight": 1.500000,
+ "description": "Recipients seems to be autogenerated (works if recipients count is more than 5)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FAKE_RECEIVED_mail_ru",
+ "weight": 4.0,
+ "description": "Fake HELO mail.ru in Received header from non-mail.ru sender address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_XOIP",
+ "weight": 0.0,
+ "description": "Has X-Originating-IP header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_DOM_NEQ_TO_DOM",
+ "weight": 0.0,
+ "description": "Reply-To domain does not match the To domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "EMPTY_SUBJECT",
+ "weight": 1.0,
+ "description": "Subject header is empty",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "STOX_REPLY_TYPE",
+ "weight": 1.0,
+ "description": "Reply-type in Content-Type header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_HEADER_CTYPE_ONLY",
+ "weight": 2.0,
+ "description": "Only Content-Type header without other MIME headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BOUNCE",
+ "weight": -0.100000,
+ "description": "(Non) Delivery Status Notification",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SORTED_RECIPS",
+ "weight": 3.500000,
+ "description": "Recipients list seems to be sorted",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INVALID_POSTFIX_RECEIVED",
+ "weight": 3.0,
+ "description": "Invalid Postfix Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ENVFROM_PRVS",
+ "weight": 0.0,
+ "description": "Envelope From is a PRVS address that matches the From address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CHECK_RECEIVED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_MIMEOLE",
+ "weight": 2.0,
+ "description": "Mime-OLE is needed but absent (e.g. fake Outlook or fake Exchange)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_HAS_DN",
+ "weight": 0.0,
+ "description": "From header has a display name",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_NO_TLS_LAST",
+ "weight": 0.100000,
+ "description": "Last hop did not use encrypted transports",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INVALID_FROM_8BIT",
+ "weight": 6.0,
+ "description": "Invalid 8bit character in From header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RATWARE_MS_HASH",
+ "weight": 2.0,
+ "description": "Forged Exchange messages",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ONCE_RECEIVED_STRICT",
+ "weight": 4.0,
+ "description": "One received header with 'bad' patterns inside",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "XM_CASE",
+ "weight": 0.500000,
+ "description": "X-mailer .vs. X-Mailer",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DATE_IN_PAST",
+ "weight": 1.0,
+ "description": "Message date is in the past",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MULTIPLE_UNIQUE_HEADERS",
+ "weight": 7.0,
+ "description": "Repeated unique headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PRIO_THREE",
+ "weight": 0.0,
+ "description": "Message has X-Priority header set to 3 or 4",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CHECK_REPLYTO",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_MIXED_CHARSET_URL",
+ "weight": 7.0,
+ "description": "Mixed characters in a URL inside message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MV_CASE",
+ "weight": 0.500000,
+ "description": "Mime-Version .vs. MIME-Version",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_UNDISC_RCPT",
+ "weight": 3.0,
+ "description": "Recipients are absent or undisclosed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "APPLE_MAILER",
+ "weight": 0.0,
+ "description": "Sent with Apple Mail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DN_ALL",
+ "weight": 0.0,
+ "description": "All the recipients have display names",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "GOOGLE_FORWARDING_MID_BROKEN",
+ "weight": 1.700000,
+ "description": "Message had invalid Message-ID pre-forwarding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_INVALID",
+ "weight": 2.0,
+ "description": "From header does not have a valid format",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DATE_IN_FUTURE",
+ "weight": 4.0,
+ "description": "Message date is in the future",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_NAME_EXCESS_SPACE",
+ "weight": 1.0,
+ "description": "From header display name contains excess whitespace",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_GENERIC_RECEIVED2",
+ "weight": 3.600000,
+ "description": "Forged generic Received header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_COUNT_THREE",
+ "weight": 0.0,
+ "description": "Message has 3-5 Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_EQ_FROM",
+ "weight": 0.0,
+ "description": "Reply-To header is identical to From header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MULTIPLE_FROM",
+ "weight": 8.0,
+ "description": "Multiple addresses in From header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_CD_HEADER",
+ "weight": 0.0,
+ "description": "Has Content-Description header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_TLS_ALL",
+ "weight": 0.0,
+ "description": "All hops used encrypted transports",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_MATCH_ENVRCPT_ALL",
+ "weight": 0.0,
+ "description": "All of the recipients match the envelope",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_VIA_SMTP_AUTH",
+ "weight": 0.0,
+ "description": "Authenticated hand-off was seen in Received headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_DN_RECIPIENTS",
+ "weight": 2.0,
+ "description": "To header display name is \"Recipients\"",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_HTML_ONLY",
+ "weight": 0.200000,
+ "description": "Message has only an HTML part",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_INTERSPIRE_SIG",
+ "weight": 1.0,
+ "description": "Has Interspire fingerprint",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJECT_HAS_CURRENCY",
+ "weight": 1.0,
+ "description": "Subject contains currency",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJ_BOUNCE_WORDS",
+ "weight": 0.0,
+ "description": "Words/phrases typical for DSN",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_REPLYTO_EMPTY_DELIMITER",
+ "weight": 1.0,
+ "description": "Reply-To header has no delimiter between header name and header value",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HEADER_TO_EMPTY_DELIMITER",
+ "weight": 1.0,
+ "description": "To header has no delimiter between header name and header value",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "phishing",
+ "rules": [
+ {
+ "symbol": "PH_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HACKED_WP_PHISHING",
+ "weight": 4.500000,
+ "description": "Phish message sent by hacked Wordpress instance",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_REDIRECTOR_NESTED",
+ "weight": 1.0,
+ "description": "URL redirector nested limit has been reached"
+ },
+ {
+ "symbol": "REDIRECTOR_FALSE",
+ "weight": 0.0,
+ "description": "Phishing exclusion symbol for known redirectors",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISHED_EXCLUDED",
+ "weight": 0.0,
+ "description": "Phished URL found in exclusions list",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISHING",
+ "weight": 4.0,
+ "description": "Phished URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISHED_OPENPHISH",
+ "weight": 7.0,
+ "description": "Phished URL found in openphish.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISHED_GENERIC_SERVICE",
+ "weight": 0.0,
+ "description": "Phished URL found in generic service",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISHED_WHITELISTED",
+ "weight": 0.0,
+ "description": "Phishing exclusion symbol for known exceptions",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISHED_PHISHTANK",
+ "weight": 7.0,
+ "description": "Phished URL found in phishtank.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "excessb64",
+ "rules": [
+ {
+ "symbol": "FROM_EXCESS_BASE64",
+ "weight": 1.500000,
+ "description": "From header is unnecessarily encoded in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REPLYTO_EXCESS_BASE64",
+ "weight": 1.500000,
+ "description": "Reply-To header is unnecessarily encoded in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TO_EXCESS_BASE64",
+ "weight": 1.500000,
+ "description": "To header is unnecessarily encoded in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CC_EXCESS_BASE64",
+ "weight": 1.500000,
+ "description": "Cc header is unnecessarily encoded in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUBJ_EXCESS_BASE64",
+ "weight": 1.500000,
+ "description": "Subject header is unnecessarily encoded in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "forwarding",
+ "rules": [
+ {
+ "symbol": "FWD_MAILRU",
+ "weight": 0.0,
+ "description": "Message was forwarded by Mail.ru",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORWARDED",
+ "weight": 0.0,
+ "description": "Message was forwarded",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FWD_GOOGLE",
+ "weight": 0.0,
+ "description": "Message was forwarded by Google",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FWD_SIEVE",
+ "weight": 0.0,
+ "description": "Message was forwarded using Sieve",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FWD_CPANEL",
+ "weight": 0.0,
+ "description": "Message was forwarded using cPanel",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FWD_YANDEX",
+ "weight": 0.0,
+ "description": "Message was forwarded by Yandex",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FWD_SRS",
+ "weight": 0.0,
+ "description": "Message was forwarded using Sender Rewriting Scheme (SRS)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "url",
+ "rules": [
+ {
+ "symbol": "HAS_FILE_URL",
+ "weight": 2.0,
+ "description": "Contains file:// URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_BAD_UNICODE",
+ "weight": 3.0,
+ "description": "URL contains invalid Unicode",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_USER_PASSWORD",
+ "weight": 2.0,
+ "description": "URL contains user field",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_OBFUSCATED_TEXT",
+ "weight": 5.0,
+ "description": "Obfuscated URL found in message text",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_VERY_LONG",
+ "weight": 1.500000,
+ "description": "URL is very long",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_HOMOGRAPH_ATTACK",
+ "weight": 5.0,
+ "description": "URL uses homograph attack (mixed scripts)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_SUSPICIOUS_TLD",
+ "weight": 3.0,
+ "description": "URL uses suspicious TLD",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_GOOGLE_REDIR",
+ "weight": 1.0,
+ "description": "Has google.com/url or alike Google redirection URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URI_COUNT_ODD",
+ "weight": 1.0,
+ "description": "Odd number of URIs in multipart/alternative message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_ZERO_WIDTH_SPACES",
+ "weight": 7.0,
+ "description": "URL contains zero-width spaces",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_USER_LONG",
+ "weight": 3.0,
+ "description": "URL user field is long (>128 chars)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_GOOGLE_FIREBASE_URL",
+ "weight": 2.0,
+ "description": "Contains firebasestorage.googleapis.com URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_IPFS_GATEWAY_URL",
+ "weight": 6.0,
+ "description": "Message contains InterPlanetary File System (IPFS) gateway URL, likely malicious",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_RTL_OVERRIDE",
+ "weight": 6.0,
+ "description": "URL uses RTL override character",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_NUMERIC_PRIVATE_IP",
+ "weight": 0.500000,
+ "description": "URL uses private IP range",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_BACKSLASH_PATH",
+ "weight": 2.0,
+ "description": "URL uses backslashes",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_NUMERIC_IP",
+ "weight": 1.500000,
+ "description": "URL uses numeric IP address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_USER_VERY_LONG",
+ "weight": 5.0,
+ "description": "URL user field is very long (>256 chars)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_ONION_URI",
+ "weight": 0.0,
+ "description": "Contains .onion hidden service URI",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_EXCESSIVE_DOTS",
+ "weight": 2.0,
+ "description": "URL has excessive dots in hostname",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_SUSPECT_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_NO_TLD",
+ "weight": 2.0,
+ "description": "URL has no TLD",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "OMOGRAPH_URL",
+ "weight": 5.0,
+ "description": "URL contains both latin and non-latin characters",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_MULTIPLE_AT_SIGNS",
+ "weight": 3.0,
+ "description": "URL has multiple @ signs",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_NUMERIC_IP_USER",
+ "weight": 4.0,
+ "description": "URL uses numeric IP with user field",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_GUC_PROXY_URI",
+ "weight": 1.0,
+ "description": "Has googleusercontent.com proxy URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "rspamdbl",
+ "rules": [
+ {
+ "symbol": "RSPAMD_URIBL",
+ "weight": 4.500000,
+ "description": "Rspamd uribl, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_EMAILBL",
+ "weight": 2.500000,
+ "description": "Rspamd emailbl, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "blocked",
+ "rules": [
+ {
+ "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_BLOCKED",
+ "weight": 0.0,
+ "description": "SURBL: query blocked by policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DNSWL_BLOCKED",
+ "weight": 0.0,
+ "description": "https://www.dnswl.org: Resolver blocked due to excessive queries",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_BLOCKED",
+ "weight": 0.0,
+ "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_BLOCKED",
+ "weight": 0.0,
+ "description": "URIBL.com: query refused, likely due to policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED",
+ "weight": 0.0,
+ "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_BLOCKED",
+ "weight": 0.0,
+ "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243"
+ },
+ {
+ "symbol": "DBL_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "blocklistde",
+ "rules": [
+ {
+ "symbol": "RECEIVED_BLOCKLISTDE",
+ "weight": 3.0,
+ "description": "Received address is listed in Blocklist (https://www.blocklist.de/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_BLOCKLISTDE",
+ "weight": 4.0,
+ "description": "From address is listed in Blocklist (https://www.blocklist.de/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "mime_types",
+ "rules": [
+ {
+ "symbol": "MIME_DOUBLE_BAD_EXTENSION",
+ "weight": 3.0,
+ "description": "Bad extension cloaking",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_TRACE",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_ARCHIVE_IN_ARCHIVE",
+ "weight": 5.0,
+ "description": "Archive within another archive",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_UNKNOWN",
+ "weight": 0.100000,
+ "description": "Missing or unknown content-type",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ENCRYPTED_PGP",
+ "weight": -0.500000,
+ "description": "Message is encrypted with PGP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_GOOD",
+ "weight": -0.100000,
+ "description": "Known content-type",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BOGUS_ENCRYPTED_AND_TEXT",
+ "weight": 10.0,
+ "description": "Bogus mix of encrypted and text/html payloads",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BAD_EXTENSION",
+ "weight": 2.0,
+ "description": "Bad extension",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_EXE_IN_GEN_SPLIT_RAR",
+ "weight": 5.0,
+ "description": "EXE file in RAR archive with generic split extension (e.g. .001)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_ENCRYPTED_ARCHIVE",
+ "weight": 2.0,
+ "description": "Encrypted archive in a message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BAD",
+ "weight": 1.0,
+ "description": "Known bad content-type",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SIGNED_SMIME",
+ "weight": -2.0,
+ "description": "Message is signed with S/MIME",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_TYPES_CALLBACK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BAD_UNICODE",
+ "weight": 2.0,
+ "description": "Filename with known obscured unicode characters",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SIGNED_PGP",
+ "weight": -2.0,
+ "description": "Message is signed with PGP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_OBFUSCATED_ARCHIVE",
+ "weight": 2.0,
+ "description": "Archive has files with clear obfuscation signs",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ENCRYPTED_SMIME",
+ "weight": -0.500000,
+ "description": "Message is encrypted with S/MIME",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BAD_ATTACHMENT",
+ "weight": 4.0,
+ "description": "Invalid attachment mime type",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "antivirus",
+ "rules": []
+ },
+ {
+ "group": "spf",
+ "rules": [
+ {
+ "symbol": "R_SPF_FAIL",
+ "weight": 1.0,
+ "description": "SPF verification failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPF_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_SPF_DKIM",
+ "weight": -3.0,
+ "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_DMARC",
+ "weight": 6.0,
+ "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_SPF_DKIM",
+ "weight": 3.0,
+ "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_PERMFAIL",
+ "weight": 0.0,
+ "description": "SPF record is malformed or persistent DNS error",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_ALLOW",
+ "weight": -0.200000,
+ "description": "SPF verification allows sending",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_SOFTFAIL",
+ "weight": 0.0,
+ "description": "SPF verification soft-failed",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_NEUTRAL",
+ "weight": 0.0,
+ "description": "SPF policy is neutral",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_PLUSALL",
+ "weight": 4.0,
+ "description": "SPF record allows to send from any IP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_DMARC",
+ "weight": -7.0,
+ "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_DNSFAIL",
+ "weight": 0.0,
+ "description": "SPF DNS failure",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SPF_NA",
+ "weight": 0.0,
+ "description": "Missing SPF record",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_SPF",
+ "weight": 1.0,
+ "description": "Mail comes from the whitelisted domain and has no valid SPF policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_SPF",
+ "weight": -1.0,
+ "description": "Mail comes from the whitelisted domain and has a valid SPF policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "hfilter",
+ "rules": [
+ {
+ "symbol": "HFILTER_URL_ONELINE",
+ "weight": 2.500000,
+ "description": "One line URL and text in body",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_3",
+ "weight": 2.0,
+ "description": "Helo host checks (medium)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HOSTNAME_1",
+ "weight": 0.500000,
+ "description": "Hostname checks (very low)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_4",
+ "weight": 2.500000,
+ "description": "Helo host checks (hard)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_BAREIP",
+ "weight": 3.0,
+ "description": "Helo host is bare ip",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HOSTNAME_4",
+ "weight": 2.500000,
+ "description": "Hostname checks (hard)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_1",
+ "weight": 0.500000,
+ "description": "Helo host checks (very low)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_5",
+ "weight": 3.0,
+ "description": "Helo host checks (very hard)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_NORESOLVE_MX",
+ "weight": 0.200000,
+ "description": "MX found in Helo and no resolve",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HOSTNAME_3",
+ "weight": 2.0,
+ "description": "Hostname checks (medium)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_RCPT_BOUNCEMOREONE",
+ "weight": 1.500000,
+ "description": "Message from bounce and over 1 recipient",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_FROMHOST_NORES_A_OR_MX",
+ "weight": 1.500000,
+ "description": "FROM host no resolve to A or MX",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_2",
+ "weight": 1.0,
+ "description": "Helo host checks (low)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_BADIP",
+ "weight": 4.500000,
+ "description": "Helo host is very bad ip",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HOSTNAME_2",
+ "weight": 1.0,
+ "description": "Hostname checks (low)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HOSTNAME_5",
+ "weight": 3.0,
+ "description": "Hostname checks (very hard)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_FROM_BOUNCE",
+ "weight": 0.0,
+ "description": "Bounce message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RDNS_DNSFAIL",
+ "weight": 0.0,
+ "description": "PTR verification DNS error",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_NOT_FQDN",
+ "weight": 2.0,
+ "description": "Helo not FQDN",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_NORES_A_OR_MX",
+ "weight": 0.300000,
+ "description": "Helo no resolve to A or MX",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_FROMHOST_NORESOLVE_MX",
+ "weight": 0.500000,
+ "description": "MX found in FROM host and no resolve",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_FROMHOST_NOT_FQDN",
+ "weight": 3.0,
+ "description": "FROM host not FQDN",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HOSTNAME_UNKNOWN",
+ "weight": 2.500000,
+ "description": "Unknown client hostname (PTR or FCrDNS verification failed)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RDNS_NONE",
+ "weight": 2.0,
+ "description": "Cannot resolve reverse DNS for sender's IP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_HELO_IP_A",
+ "weight": 1.0,
+ "description": "Helo A IP != hostname IP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HFILTER_URL_ONLY",
+ "weight": 2.200000,
+ "description": "URL only in body",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "spamhaus",
+ "rules": [
+ {
+ "symbol": "RBL_SPAMHAUS_DROP",
+ "weight": 7.0,
+ "description": "From address is listed in Spamhaus DROP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_PBL",
+ "weight": 2.0,
+ "description": "From address is listed in Spamhaus PBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_BOTNET",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_PROHIBIT",
+ "weight": 0.0,
+ "description": "DBL uribl IP queries prohibited!",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPAMHAUS_ZEN_URIBL",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus ZEN URIBL"
+ },
+ {
+ "symbol": "RBL_SPAMHAUS",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus ZEN",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_BLOCKED",
+ "weight": 0.0,
+ "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BOTNET",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_PBL",
+ "weight": 0.0,
+ "description": "Received address is listed in Spamhaus PBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_SBL",
+ "weight": 6.500000,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL"
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_SBL",
+ "weight": 4.0,
+ "description": "From address is listed in Spamhaus SBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_SBL",
+ "weight": 3.0,
+ "description": "Received address is listed in Spamhaus SBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_REDIR",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_CSS",
+ "weight": 2.0,
+ "description": "From address is listed in Spamhaus CSS",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_PHISH",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_XBL",
+ "weight": 1.0,
+ "description": "Received address is listed in Spamhaus XBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_SPAM",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as spam",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_PBL",
+ "weight": 0.010000,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL"
+ },
+ {
+ "symbol": "URIBL_DROP",
+ "weight": 5.0,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP"
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_CSS",
+ "weight": 1.0,
+ "description": "Received address is listed in Spamhaus CSS",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_PHISH",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_ABUSE_MALWARE",
+ "weight": 6.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SPAMHAUS_XBL",
+ "weight": 4.0,
+ "description": "From address is listed in Spamhaus XBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL",
+ "weight": 0.0,
+ "description": "Unrecognised result from Spamhaus DBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_MALWARE",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in Spamhaus DBL as malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER",
+ "weight": 0.0,
+ "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_XBL",
+ "weight": 3.0,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL"
+ },
+ {
+ "symbol": "URIBL_SBL_CSS",
+ "weight": 5.0,
+ "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS"
+ },
+ {
+ "symbol": "RECEIVED_SPAMHAUS_DROP",
+ "weight": 6.0,
+ "description": "Received address is listed in Spamhaus DROP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "ebl",
+ "rules": [
+ {
+ "symbol": "MSBL_EBL",
+ "weight": 7.500000,
+ "description": "MSBL emailbl (https://www.msbl.org/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MSBL_EBL_GREY",
+ "weight": 0.500000,
+ "description": "MSBL emailbl grey list (https://www.msbl.org/)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "surblorg",
+ "rules": [
+ {
+ "symbol": "CRACKED_SURBL",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in SURBL as cracked",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_BLOCKED",
+ "weight": 0.0,
+ "description": "SURBL: query blocked by policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PH_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as phishing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ABUSE_SURBL",
+ "weight": 5.0,
+ "description": "A domain in the message is listed in SURBL as abused",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CT_SURBL",
+ "weight": 0.0,
+ "description": "A domain in the message is listed in SURBL as a clicktracker",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MW_SURBL_MULTI",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in SURBL as malware",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DM_SURBL",
+ "weight": 0.0,
+ "description": "A domain in the message is listed in SURBL as belonging to a disposable email service",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "uribl",
+ "rules": [
+ {
+ "symbol": "URIBL_GREY",
+ "weight": 2.500000,
+ "description": "A domain in the message is listed in URIBL.com grey",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_MULTI",
+ "weight": 0.0,
+ "description": "Unrecognised result from URIBL.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_BLOCKED",
+ "weight": 0.0,
+ "description": "URIBL.com: query refused, likely due to policy/overusage",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_BLACK",
+ "weight": 7.500000,
+ "description": "A domain in the message is listed in URIBL.com black",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_RED",
+ "weight": 0.500000,
+ "description": "A domain in the message is listed in URIBL.com red",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "external_services",
+ "rules": []
+ },
+ {
+ "group": "experimental",
+ "rules": [
+ {
+ "symbol": "XM_UA_NO_VERSION",
+ "weight": 0.010000,
+ "description": "X-Mailer/User-Agent header has no version number",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "composite",
+ "rules": [
+ {
+ "symbol": "SUSPICIOUS_AUTH_ORIGIN",
+ "weight": 0.0,
+ "description": "Message authenticated, but from a suspicios origin (potentially an injector)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_RECIPIENTS_FORWARDING",
+ "weight": 0.0,
+ "description": "FORGED_RECIPIENTS & g:forwarding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "UNDISC_RCPTS_BULK",
+ "weight": 3.0,
+ "description": "Missing or undisclosed recipients with a bulk signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_URL_IN_SUSPICIOUS_MESSAGE",
+ "weight": 1.0,
+ "description": "Message contains redirector, anonymous or IPFS gateway URL and is marked by fuzzy/bayes/SURBL/RBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_UNAUTH_PBL",
+ "weight": 2.0,
+ "description": "Relayed through Spamhaus PBL IP without sufficient authentication (possibly indicating an open relay)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "APPLE_MAILER_COMMON",
+ "weight": 0.0,
+ "description": "Message was sent by 'Apple Mail' and has common symbols in place",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_SENDER_MAILLIST",
+ "weight": 0.0,
+ "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHISH_EMOTION",
+ "weight": 1.0,
+ "description": "Phish message with subject trying to address users emotion",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES",
+ "weight": -0.500000,
+ "description": "DMARC permit policy with DKIM/SPF failure",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "AUTH_NA_OR_FAIL",
+ "weight": 1.0,
+ "description": "No authenticating method SPF/DKIM/DMARC/ARC was successful",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "REDIRECTOR_URL_ONLY",
+ "weight": 1.0,
+ "description": "Message only contains a redirector URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_RECIPIENTS_MAILLIST",
+ "weight": 0.0,
+ "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_SENDER_VERP_SRS",
+ "weight": 0.0,
+ "description": "FORGED_SENDER & (ENVFROM_PRVS | ENVFROM_VERP)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_ANON_DOMAIN",
+ "weight": 0.100000,
+ "description": "Contains one or more domains trying to disguise owner/destination",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BROKEN_HEADERS_MAILLIST",
+ "weight": 0.0,
+ "description": "Negate BROKEN_HEADERS when message comes via some mailing list",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "AUTOGEN_PHP_SPAMMY",
+ "weight": 1.0,
+ "description": "Message was generated by PHP script and contains some spam indicators",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "APPLE_IOS_MAILER_COMMON",
+ "weight": 0.0,
+ "description": "Message was sent by 'Apple iOS Mail' and has common symbols in place",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "IP_SCORE_FREEMAIL",
+ "weight": 0.0,
+ "description": "Negate IP_SCORE when message comes from FreeMail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "VIOLATED_DIRECT_SPF",
+ "weight": 3.500000,
+ "description": "Has no Received (or no trusted received relays) and SPF policy fails or soft fails",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "AUTH_NA",
+ "weight": 1.0,
+ "description": "Authenticating message via SPF/DKIM/DMARC/ARC not available",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_REPLYTO_NEQ_FROM",
+ "weight": 2.0,
+ "description": "Reply-To is a Freemail address and it not match From header or SMTP From, also From is not another Freemail",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BAD_EXT_IN_OBFUSCATED_ARCHIVE",
+ "weight": 8.0,
+ "description": "Attachment with bad extension and archive that has filename with clear obfuscation signs",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BAD_REP_POLICIES",
+ "weight": 0.100000,
+ "description": "Contains valid policies but are also marked by fuzzy/bayes/SURBL/RBL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_MID_ALLOWED",
+ "weight": 0.0,
+ "description": "MISSING_MID_ALLOWED",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_MAILLIST",
+ "weight": 0.0,
+ "description": "Avoid false positives for FORGED_MUA_* in maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPF_FAIL_FORWARDING",
+ "weight": 0.0,
+ "description": "g:forwarding & (R_SPF_SOFTFAIL | R_SPF_FAIL)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INVALID_MSGID_ALLOWED",
+ "weight": 0.0,
+ "description": "INVALID_MSGID_ALLOWED",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_DKIM_ARC_DNSWL_HI",
+ "weight": -1.0,
+ "description": "Sufficiently DKIM/ARC signed and received from IP with high trust at DNSWL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_SENDER_FORWARDING",
+ "weight": 0.0,
+ "description": "Forged sender, but message is forwarded",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MIME_BAD_EXT_WITH_BAD_UNICODE",
+ "weight": 8.0,
+ "description": "Attachment with bad extension and filename that has known obscured unicode characters",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_DKIM_ARC_DNSWL_MED",
+ "weight": -0.500000,
+ "description": "Sufficiently DKIM/ARC signed and received from IP with medium trust at DNSWL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_MIXED",
+ "weight": 0.0,
+ "description": "-R_DKIM_ALLOW & (R_DKIM_TEMPFAIL | R_DKIM_PERMFAIL | R_DKIM_REJECT)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BOUNCE_NO_AUTH",
+ "weight": 1.0,
+ "description": "(AUTH_NA | AUTH_NA_OR_FAIL) & (BOUNCE | SUBJ_BOUNCE_WORDS)",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "mid",
+ "rules": [
+ {
+ "symbol": "MID_END_EQ_FROM_USER_PART",
+ "weight": 4.0,
+ "description": "Message-ID RHS (after @) and MIME from local part are the same",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "CHECK_MID",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "KNOWN_MID",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "KNOWN_NO_MID",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "KNOWN_MID_CALLBACK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "fuzzy",
+ "rules": [
+ {
+ "symbol": "FUZZY_DENIED",
+ "weight": 12.0,
+ "description": "Denied fuzzy hash, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_PROB",
+ "weight": 5.0,
+ "description": "Probable fuzzy hash, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_ENCRYPTION_REQUIRED",
+ "weight": 0.0,
+ "description": "Fuzzy encryption is required by a server",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_WHITE",
+ "weight": -2.100000,
+ "description": "Whitelisted fuzzy hash, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_FORBIDDEN",
+ "weight": 0.0,
+ "description": "Fuzzy access denied",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_RATELIMITED",
+ "weight": 0.0,
+ "description": "Fuzzy rate limit is reached",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_UNKNOWN",
+ "weight": 5.0,
+ "description": "Generic fuzzy hash match, bl.rspamd.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FUZZY_CALLBACK",
+ "weight": 0.0,
+ "description": "Fuzzy check callback",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "senderscore",
+ "rules": [
+ {
+ "symbol": "RBL_SENDERSCORE_NA",
+ "weight": 0.0,
+ "description": "From address is listed in SenderScore RPBL - noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_2",
+ "weight": 3.0,
+ "description": "SenderScore Reputation: Bad (20-29).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_NA",
+ "weight": 1.0,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_9",
+ "weight": -1.0,
+ "description": "SenderScore Reputation: Good (90-100).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_4",
+ "weight": 2.0,
+ "description": "SenderScore Reputation: Bad (40-49).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_1",
+ "weight": 3.500000,
+ "description": "SenderScore Reputation: Bad (10-19).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN",
+ "weight": 0.0,
+ "description": "Unrecognized result from SenderScore Reputation list.",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_NA",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_BLOCKED",
+ "weight": 0.0,
+ "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_8",
+ "weight": 0.0,
+ "description": "SenderScore Reputation: Neutral (80-89).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA",
+ "weight": 4.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST_NA",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - pristine+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST_NA_BOT",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST_BOT",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - pristine+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_6",
+ "weight": 1.0,
+ "description": "SenderScore Reputation: Bad (60-69).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_PRST",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - pristine"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_0",
+ "weight": 4.0,
+ "description": "SenderScore Reputation: Very Bad (0-9).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT",
+ "weight": 1.0,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SCORE_PRST",
+ "weight": 4.0,
+ "description": "From address is listed in SenderScore RPBL - sender_score+pristine"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_3",
+ "weight": 2.500000,
+ "description": "SenderScore Reputation: Bad (30-39).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_5",
+ "weight": 1.500000,
+ "description": "SenderScore Reputation: Bad (50-59).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA",
+ "weight": 3.0,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_NA_BOT",
+ "weight": 1.0,
+ "description": "From address is listed in SenderScore RPBL - noauth+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_7",
+ "weight": 0.500000,
+ "description": "SenderScore Reputation: Bad (70-79).",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT",
+ "weight": 1.500000,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT",
+ "weight": 3.500000,
+ "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_BOT",
+ "weight": 2.0,
+ "description": "From address is listed in SenderScore RPBL - botnet"
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED",
+ "weight": 0.0,
+ "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "aliases",
+ "rules": [
+ {
+ "symbol": "TAGGED_RCPT",
+ "weight": 0.0,
+ "description": "Recipient has plus-tags",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "TAGGED_FROM",
+ "weight": 0.0,
+ "description": "From address has plus-tags",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INTERNAL_MAIL",
+ "weight": 0.0,
+ "description": "Mail from local to local domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ALIASES_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "LOCAL_INBOUND",
+ "weight": 0.0,
+ "description": "Mail from external to local domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ALIAS_RESOLVED",
+ "weight": 0.0,
+ "description": "Address was resolved through aliases",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "LOCAL_OUTBOUND",
+ "weight": 0.0,
+ "description": "Mail from local to external domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "malware",
+ "rules": [
+ {
+ "symbol": "EXE_ARCHIVE_CLICKBAIT_FILENAME",
+ "weight": 9.0,
+ "description": "exe file in archive with clickbait filename",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "EXE_ARCHIVE_CLICKBAIT_SUBJECT",
+ "weight": 9.0,
+ "description": "exe file in archive with clickbait subject",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISIDENTIFIED_RAR",
+ "weight": 4.0,
+ "description": "rar with wrong extension",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "EXE_IN_ARCHIVE",
+ "weight": 1.500000,
+ "description": "exe file in archive",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "EXE_IN_MISIDENTIFIED_RAR",
+ "weight": 5.0,
+ "description": "rar with wrong extension containing exe file",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SINGLE_FILE_ARCHIVE_WITH_EXE",
+ "weight": 5.0,
+ "description": "single file container bearing executable",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "mailspike",
+ "rules": [
+ {
+ "symbol": "MAILSPIKE",
+ "weight": 0.0,
+ "description": "Unrecognised result from Mailspike",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_MAILSPIKE_BAD",
+ "weight": 1.0,
+ "description": "From address is listed in Mailspike RBL - bad reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_MAILSPIKE_VERYBAD",
+ "weight": 1.500000,
+ "description": "From address is listed in Mailspike RBL - very bad reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_GOOD",
+ "weight": -0.100000,
+ "description": "From address is listed in Mailspike RWL - good reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_VERYGOOD",
+ "weight": -0.200000,
+ "description": "From address is listed in Mailspike RWL - very good reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_POSSIBLE",
+ "weight": 0.0,
+ "description": "From address is listed in Mailspike RWL - possibly legit",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_EXCELLENT",
+ "weight": -0.400000,
+ "description": "From address is listed in Mailspike RWL - excellent reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RWL_MAILSPIKE_NEUTRAL",
+ "weight": 0.0,
+ "description": "Neutral result from Mailspike",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_MAILSPIKE_WORST",
+ "weight": 2.0,
+ "description": "From address is listed in Mailspike RBL - worst possible reputation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "compromised_hosts",
+ "rules": [
+ {
+ "symbol": "URI_HIDDEN_PATH",
+ "weight": 1.0,
+ "description": "Message contains URI with a hidden path",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "XAW_SERVICE_ACCT",
+ "weight": 1.0,
+ "description": "Message originally from a service account",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HIDDEN_SOURCE_OBJ",
+ "weight": 2.0,
+ "description": "UNIX hidden file/directory in path",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_PHPMAILER_SIG",
+ "weight": 0.0,
+ "description": "PHPMailer signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WWW_DOT_DOMAIN",
+ "weight": 0.500000,
+ "description": "From/Sender/Reply-To or Envelope is @www.domain.com",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_SOURCE",
+ "weight": 0.0,
+ "description": "Has X-Source headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HACKED_WP_PHISHING",
+ "weight": 4.500000,
+ "description": "Phish message sent by hacked Wordpress instance",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_XAW",
+ "weight": 0.0,
+ "description": "Has X-Authentication-Warning header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_PHP_SCRIPT",
+ "weight": 0.0,
+ "description": "Has X-PHP-Script header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHP_SCRIPT_ROOT",
+ "weight": 1.0,
+ "description": "PHP Script executed by root UID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PHP_XPS_PATTERN",
+ "weight": 0.0,
+ "description": "Message contains X-PHP-Script pattern",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_AS",
+ "weight": 0.0,
+ "description": "Has X-Authenticated-Sender header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "COMPROMISED_ACCT_BULK",
+ "weight": 3.0,
+ "description": "Likely to be from a compromised account",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "X_PHP_EVAL",
+ "weight": 4.0,
+ "description": "Message sent using eval'd PHP",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_POS",
+ "weight": 0.0,
+ "description": "Has X-PHP-Originating-Script header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_WP_URI",
+ "weight": 0.0,
+ "description": "Contains WordPress URIs",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ABUSE_FROM_INJECTOR",
+ "weight": 2.0,
+ "description": "Message is sent from a suspicios origin and showing signs of abuse, likely spam injected in compromised account",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_GMSV",
+ "weight": 0.0,
+ "description": "Has X-Get-Message-Sender-Via: header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FROM_SERVICE_ACCT",
+ "weight": 1.0,
+ "description": "Sender/From/Reply-To is a service account",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ENVFROM_SERVICE_ACCT",
+ "weight": 1.0,
+ "description": "Envelope from is a service account",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_X_ANTIABUSE",
+ "weight": 0.0,
+ "description": "Has X-AntiAbuse headers",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WP_COMPROMISED",
+ "weight": 0.0,
+ "description": "URL that is pointing to a compromised WordPress installation",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_RHS_WWW",
+ "weight": 0.500000,
+ "description": "Message-ID from www host",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "html",
+ "rules": [
+ {
+ "symbol": "ZERO_FONT",
+ "weight": 1.0,
+ "description": "Zero sized font used",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTML_SHORT_LINK_IMG_1",
+ "weight": 2.0,
+ "description": "Short HTML part (0..1K) with a link to an image",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_WHITE_ON_WHITE",
+ "weight": 4.0,
+ "description": "Message contains low contrast text",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTML_SHORT_LINK_IMG_2",
+ "weight": 1.0,
+ "description": "Short HTML part (1K..1.5K) with a link to an image",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTML_VISIBLE_CHECKS",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTML_SHORT_LINK_IMG_3",
+ "weight": 0.500000,
+ "description": "Short HTML part (1.5K..2K) with a link to an image",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HAS_DATA_URI",
+ "weight": 0.0,
+ "description": "Has Data URI encoding",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTTP_TO_IP",
+ "weight": 1.0,
+ "description": "HTML anchor points to an IP address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_EMPTY_IMAGE",
+ "weight": 2.0,
+ "description": "Message contains empty parts and image",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MANY_INVISIBLE_PARTS",
+ "weight": 1.0,
+ "description": "Many parts are visually hidden",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_SUSPICIOUS_IMAGES",
+ "weight": 5.0,
+ "description": "Message has high image to text ratio",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTTP_TO_HTTPS",
+ "weight": 0.500000,
+ "description": "The anchor text contains a distinct scheme compared to the target URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "EXT_CSS",
+ "weight": 1.0,
+ "description": "Message contains external CSS reference",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DATA_URI_OBFU",
+ "weight": 2.0,
+ "description": "Uses Data URI encoding to obfuscate plain or HTML in base64",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "HTML_META_REFRESH_URL",
+ "weight": 5.0,
+ "description": "Has HTML Meta refresh URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "subject",
+ "rules": [
+ {
+ "symbol": "SUBJ_ALL_CAPS",
+ "weight": 3.0,
+ "description": "Subject contains mostly capital letters",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "LONG_SUBJ",
+ "weight": 3.0,
+ "description": "Subject is very long",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URL_IN_SUBJECT",
+ "weight": 4.0,
+ "description": "Subject contains URL",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "ungrouped",
+ "rules": [
+ {
+ "symbol": "ARC_SIGNED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ASN",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DKIM_SIGNED",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLOCKLISTDE_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DWL_DNSWL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MSBL_EBL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MAILSPIKE_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPAMHAUS_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_FRESH15_UNKNOWN_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SPF_CHECK",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_HASHBL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SEM_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RCVD_IN_DNSWL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SINGLE_SHORT_PART",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SURBL_MULTI_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "UDF_COMPRESSION_500PLUS",
+ "weight": 9.0,
+ "description": "very well compressed img file in archive",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "ASN_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_VIRUSFREE_UNKNOWN_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_EMAILBL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "URIBL_MULTI_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "DBL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RSPAMD_URIBL_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "RBL_SEM_IPV6_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SEM_URIBL_UNKNOWN_FAIL",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "mua",
+ "rules": [
+ {
+ "symbol": "FORGED_MUA_THEBAT_MSGID_UNKNOWN",
+ "weight": 3.0,
+ "description": "Message pretends to be send from The Bat! but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_KMAIL_MSGID_UNKNOWN",
+ "weight": 2.500000,
+ "description": "Message pretends to be send from KMail but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_OPERA_MSGID",
+ "weight": 4.0,
+ "description": "Message pretends to be send from Opera Mail but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_SEAMONKEY_MSGID",
+ "weight": 4.0,
+ "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_SEAMONKEY_MSGID_UNKNOWN",
+ "weight": 2.500000,
+ "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_OUTLOOK",
+ "weight": 3.0,
+ "description": "Forged Outlook MUA",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_BOUNDARY2",
+ "weight": 4.0,
+ "description": "Suspicious boundary in Content-Type header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_THEBAT_MSGID",
+ "weight": 4.0,
+ "description": "Message pretends to be send from The Bat! but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_BOUNDARY3",
+ "weight": 3.0,
+ "description": "Suspicious boundary in Content-Type header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_BOUNDARY4",
+ "weight": 4.0,
+ "description": "Suspicious boundary in Content-Type header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_BOUNDARY",
+ "weight": 5.0,
+ "description": "Suspicious boundary in Content-Type header",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_POSTBOX_MSGID_UNKNOWN",
+ "weight": 2.500000,
+ "description": "Forged mail pretending to be from Postbox but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID",
+ "weight": 4.0,
+ "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_THUNDERBIRD_MSGID_UNKNOWN",
+ "weight": 2.500000,
+ "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_MAILLIST",
+ "weight": 0.0,
+ "description": "Avoid false positives for FORGED_MUA_* in maillist",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_THUNDERBIRD_MSGID",
+ "weight": 4.0,
+ "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID_UNKNOWN",
+ "weight": 2.500000,
+ "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FORGED_MUA_POSTBOX_MSGID",
+ "weight": 4.0,
+ "description": "Forged mail pretending to be from Postbox but has forged Message-ID",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "whitelist",
+ "rules": [
+ {
+ "symbol": "WHITELIST_DKIM",
+ "weight": -1.0,
+ "description": "Mail comes from the whitelisted domain and has a valid DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_SPF_DKIM",
+ "weight": -3.0,
+ "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_DMARC",
+ "weight": 6.0,
+ "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_DMARC",
+ "weight": -7.0,
+ "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_SPF_DKIM",
+ "weight": 3.0,
+ "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_DKIM",
+ "weight": 2.0,
+ "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "WHITELIST_SPF",
+ "weight": -1.0,
+ "description": "Mail comes from the whitelisted domain and has a valid SPF policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BLACKLIST_SPF",
+ "weight": 1.0,
+ "description": "Mail comes from the whitelisted domain and has no valid SPF policy",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "blankspam",
+ "rules": [
+ {
+ "symbol": "COMPLETELY_EMPTY",
+ "weight": 15.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SHORT_PART_BAD_HEADERS",
+ "weight": 7.0,
+ "description": "MISSING_ESSENTIAL_HEADERS & SINGLE_SHORT_PART",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MISSING_ESSENTIAL_HEADERS",
+ "weight": 7.0,
+ "description": "Common headers were entirely absent",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "content",
+ "rules": [
+ {
+ "symbol": "PDF_TIMEOUT",
+ "weight": 0.0,
+ "description": "There is a PDF in the message that caused timeout in processing",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PDF_LONG_TRAILER",
+ "weight": 0.200000,
+ "description": "There is an PDF with a long trailer in the message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PDF_JAVASCRIPT",
+ "weight": 0.100000,
+ "description": "There is an PDF with JavaScript in the message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PDF_MANY_OBJECTS",
+ "weight": 0.0,
+ "description": "There is a PDF with too many objects in the message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PDF_ENCRYPTED",
+ "weight": 0.300000,
+ "description": "There is an encrypted PDF in the message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "PDF_SUSPICIOUS",
+ "weight": 4.500000,
+ "description": "There is an PDF with suspicious properties in the message",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "Message ID",
+ "rules": [
+ {
+ "symbol": "MID_CONTAINS_TO",
+ "weight": 1.0,
+ "description": "Message-ID contains To address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_MISSING_BRACKETS",
+ "weight": 0.500000,
+ "description": "Message-ID is missing <>'s",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_RHS_MATCH_TO",
+ "weight": 1.0,
+ "description": "Message-ID RHS matches To domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_RHS_NOT_FQDN",
+ "weight": 0.500000,
+ "description": "Message-ID RHS is not a fully-qualified domain name",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_RHS_MATCH_FROM",
+ "weight": 0.0,
+ "description": "Message-ID RHS matches From domain",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_CONTAINS_FROM",
+ "weight": 1.0,
+ "description": "Message-ID contains From address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_BARE_IP",
+ "weight": 2.0,
+ "description": "Message-ID RHS is a bare IP address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_RHS_IP_LITERAL",
+ "weight": 0.500000,
+ "description": "Message-ID RHS is an IP-literal",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "MID_RHS_MATCH_FROMTLD",
+ "weight": 0.0,
+ "description": "Message-ID RHS matches From domain tld",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "headers,mime",
+ "rules": [
+ {
+ "symbol": "CHECK_TO_CC",
+ "weight": 0.0,
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "scams",
+ "rules": [
+ {
+ "symbol": "LEAKED_PASSWORD_SCAM_RE",
+ "weight": 0.0,
+ "description": "Contains BTC wallet address and malicious regexps",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "FREEMAIL_AFF",
+ "weight": 4.0,
+ "description": "Message exhibits strong characteristics of advance fee fraud (AFF a/k/a '419' spam) involving freemail addresses",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "INTRODUCTION",
+ "weight": 2.0,
+ "description": "Sender introduces themselves",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "SUSPICIOUS_MDN",
+ "weight": 2.0,
+ "description": "Message delivery notification should go to freemail or disposable e-mail, but message was not sent from a freemail address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "BITCOIN_ADDR",
+ "weight": 0.0,
+ "description": "Message has a valid bitcoin wallet address",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "LEAKED_PASSWORD_SCAM",
+ "weight": 7.0,
+ "description": "Contains BTC wallet address and scam patterns",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ },
+ {
+ "group": "body",
+ "rules": [
+ {
+ "symbol": "HAS_ATTACHMENT",
+ "weight": 0.0,
+ "description": "Message contains attachments",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ },
+ {
+ "symbol": "R_PARTS_DIFFER",
+ "weight": 1.0,
+ "description": "Text and HTML parts differ",
+ "frequency": 0.0,
+ "frequency_stddev": 0.0,
+ "time": 0.0
+ }
+ ]
+ }
+]
diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go
new file mode 100644
index 0000000..a0955ef
--- /dev/null
+++ b/pkg/analyzer/rspamd.go
@@ -0,0 +1,174 @@
+// 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/model"
+)
+
+// Default rspamd action thresholds (rspamd built-in defaults)
+const (
+ rspamdDefaultRejectThreshold float32 = 15
+ rspamdDefaultAddHeaderThreshold float32 = 6
+)
+
+// RspamdAnalyzer analyzes rspamd results from email headers
+type RspamdAnalyzer struct {
+ symbols map[string]string
+}
+
+// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
+func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
+ return &RspamdAnalyzer{symbols: symbols}
+}
+
+// AnalyzeRspamd extracts and analyzes rspamd results from email headers
+func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
+ headers := email.GetRspamdHeaders()
+ if len(headers) == 0 {
+ return nil
+ }
+
+ // Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
+ _, hasSpamdResult := headers["X-Spamd-Result"]
+ _, hasRspamdScore := headers["X-Rspamd-Score"]
+ if !hasSpamdResult && !hasRspamdScore {
+ return nil
+ }
+
+ result := &model.RspamdResult{
+ Symbols: make(map[string]model.SpamTestDetail),
+ }
+
+ // Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
+ // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
+ if spamdResult, ok := headers["X-Spamd-Result"]; ok {
+ report := strings.ReplaceAll(spamdResult, "; ", ";\n")
+ result.Report = &report
+ a.parseSpamdResult(spamdResult, result)
+ }
+
+ // Parse X-Rspamd-Score as override/fallback for score
+ if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
+ if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
+ result.Score = float32(score)
+ }
+ }
+
+ // Parse X-Rspamd-Server
+ if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
+ server := strings.TrimSpace(serverHeader)
+ result.Server = &server
+ }
+
+ // Populate symbol descriptions from the lookup map
+ if a.symbols != nil {
+ for name, sym := range result.Symbols {
+ if desc, ok := a.symbols[name]; ok {
+ sym.Description = &desc
+ result.Symbols[name] = sym
+ }
+ }
+ }
+
+ // Derive IsSpam from score vs reject threshold.
+ if result.Threshold > 0 {
+ result.IsSpam = result.Score >= result.Threshold
+ } else {
+ result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
+ }
+
+ return result
+}
+
+// parseSpamdResult parses the X-Spamd-Result header
+// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
+func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) {
+ // Extract score and threshold from the first line
+ // e.g. "default: False [-3.91 / 15.00]"
+ scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
+ if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
+ if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
+ result.Score = float32(score)
+ }
+ if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
+ result.Threshold = float32(threshold)
+
+ // No threshold? use default AddHeaderThreshold
+ if result.Threshold <= 0 {
+ result.Threshold = rspamdDefaultAddHeaderThreshold
+ }
+ }
+ }
+
+ // Parse is_spam from header (before we may get action from X-Rspamd-Action)
+ firstLine := strings.SplitN(header, ";", 2)[0]
+ if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
+ result.IsSpam = true
+ }
+
+ // Parse symbols: SYMBOL(score)[params]
+ // Each symbol entry is separated by ";", so within each part we use a
+ // greedy match to capture params that may contain nested brackets.
+ symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
+ for _, part := range strings.Split(header, ";") {
+ part = strings.TrimSpace(part)
+ matches := symbolRe.FindStringSubmatch(part)
+ if len(matches) > 2 {
+ name := matches[1]
+ score, _ := strconv.ParseFloat(matches[2], 64)
+ sym := model.SpamTestDetail{
+ Name: name,
+ Score: float32(score),
+ }
+ if len(matches) > 3 && matches[3] != "" {
+ params := matches[3]
+ sym.Params = ¶ms
+ }
+ result.Symbols[name] = sym
+ }
+ }
+}
+
+// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
+func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) {
+ if result == nil {
+ return 100, "" // rspamd not installed
+ }
+
+ threshold := result.Threshold
+ percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
+
+ if percentage > 100 {
+ return 100, "A+"
+ } else if percentage < 0 {
+ return 0, "F"
+ }
+
+ // Linear scale between 0 and threshold
+ return percentage, ScoreToGrade(percentage)
+}
diff --git a/pkg/analyzer/rspamd_symbols.go b/pkg/analyzer/rspamd_symbols.go
new file mode 100644
index 0000000..e50a452
--- /dev/null
+++ b/pkg/analyzer/rspamd_symbols.go
@@ -0,0 +1,105 @@
+// 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 (
+ _ "embed"
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+)
+
+//go:embed rspamd-symbols.json
+var embeddedRspamdSymbols []byte
+
+// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON.
+type rspamdSymbolGroup struct {
+ Group string `json:"group"`
+ Rules []rspamdSymbolEntry `json:"rules"`
+}
+
+// rspamdSymbolEntry represents a single rspamd symbol entry.
+type rspamdSymbolEntry struct {
+ Symbol string `json:"symbol"`
+ Description string `json:"description"`
+ Weight float64 `json:"weight"`
+}
+
+// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map.
+func parseRspamdSymbolsJSON(data []byte) map[string]string {
+ var groups []rspamdSymbolGroup
+ if err := json.Unmarshal(data, &groups); err != nil {
+ log.Printf("Failed to parse rspamd symbols JSON: %v", err)
+ return nil
+ }
+
+ symbols := make(map[string]string, len(groups)*10)
+ for _, g := range groups {
+ for _, r := range g.Rules {
+ if r.Description != "" {
+ symbols[r.Symbol] = r.Description
+ }
+ }
+ }
+ return symbols
+}
+
+// LoadRspamdSymbols loads rspamd symbol descriptions.
+// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error.
+func LoadRspamdSymbols(apiURL string) map[string]string {
+ if apiURL != "" {
+ if symbols := fetchRspamdSymbols(apiURL); symbols != nil {
+ return symbols
+ }
+ log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL)
+ }
+ return parseRspamdSymbolsJSON(embeddedRspamdSymbols)
+}
+
+// fetchRspamdSymbols fetches symbol descriptions from the rspamd API.
+func fetchRspamdSymbols(apiURL string) map[string]string {
+ url := strings.TrimRight(apiURL, "/") + "/symbols"
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ log.Printf("Error fetching rspamd symbols: %v", err)
+ return nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ log.Printf("rspamd API returned status %d", resp.StatusCode)
+ return nil
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Printf("Error reading rspamd symbols response: %v", err)
+ return nil
+ }
+
+ return parseRspamdSymbolsJSON(body)
+}
diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go
new file mode 100644
index 0000000..9804f1d
--- /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/model"
+)
+
+func TestAnalyzeRspamdNoHeaders(t *testing.T) {
+ analyzer := NewRspamdAnalyzer(nil)
+ email := &EmailMessage{Header: make(mail.Header)}
+
+ result := analyzer.AnalyzeRspamd(email)
+
+ if result != nil {
+ t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
+ }
+}
+
+func TestParseSpamdResult(t *testing.T) {
+ tests := []struct {
+ name string
+ header string
+ expectedScore float32
+ expectedThreshold float32
+ expectedIsSpam bool
+ expectedSymbols map[string]float32
+ expectedSymParams map[string]string
+ }{
+ {
+ name: "Clean email negative score",
+ header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
+ expectedScore: -3.91,
+ expectedThreshold: 15.00,
+ expectedIsSpam: false,
+ expectedSymbols: map[string]float32{
+ "DATE_IN_PAST": 0.10,
+ "ALL_TRUSTED": -1.00,
+ },
+ expectedSymParams: map[string]string{
+ "ALL_TRUSTED": "trusted",
+ },
+ },
+ {
+ name: "Spam email True flag",
+ header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
+ expectedScore: 16.50,
+ expectedThreshold: 15.00,
+ expectedIsSpam: true,
+ expectedSymbols: map[string]float32{
+ "BAYES_99": 5.00,
+ "SPOOFED_SENDER": 3.50,
+ },
+ expectedSymParams: map[string]string{
+ "BAYES_99": "1.00",
+ },
+ },
+ {
+ name: "Zero threshold uses default",
+ header: "default: False [1.00 / 0.00]",
+ expectedScore: 1.00,
+ expectedThreshold: rspamdDefaultAddHeaderThreshold,
+ expectedIsSpam: false,
+ expectedSymbols: map[string]float32{},
+ },
+ {
+ name: "Symbol without params",
+ header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
+ expectedScore: 2.00,
+ expectedThreshold: 15.00,
+ expectedIsSpam: false,
+ expectedSymbols: map[string]float32{
+ "MISSING_DATE": 1.00,
+ },
+ },
+ {
+ name: "Case-insensitive true flag",
+ header: "default: true [8.00 / 6.00]",
+ expectedScore: 8.00,
+ expectedThreshold: 6.00,
+ expectedIsSpam: true,
+ expectedSymbols: map[string]float32{},
+ },
+ {
+ name: "Zero threshold with symbols containing nested brackets in params",
+ header: "default: False [0.90 / 0.00];\n" +
+ "\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
+ "\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
+ "\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
+ expectedScore: 0.90,
+ expectedThreshold: rspamdDefaultAddHeaderThreshold,
+ expectedIsSpam: false,
+ expectedSymbols: map[string]float32{
+ "ARC_REJECT": 1.00,
+ "MIME_GOOD": -0.10,
+ "MIME_TRACE": 0.00,
+ },
+ expectedSymParams: map[string]string{
+ "ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
+ "MIME_GOOD": "multipart/alternative,text/plain",
+ "MIME_TRACE": "0:+,1:+,2:~",
+ },
+ },
+ }
+
+ analyzer := NewRspamdAnalyzer(nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := &model.RspamdResult{
+ Symbols: make(map[string]model.SpamTestDetail),
+ }
+ analyzer.parseSpamdResult(tt.header, result)
+
+ if result.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
+ }
+ if result.Threshold != tt.expectedThreshold {
+ t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
+ }
+ if result.IsSpam != tt.expectedIsSpam {
+ t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
+ }
+ for symName, expectedScore := range tt.expectedSymbols {
+ sym, ok := result.Symbols[symName]
+ if !ok {
+ t.Errorf("Symbol %s not found", symName)
+ continue
+ }
+ if sym.Score != expectedScore {
+ t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
+ }
+ }
+ for symName, expectedParam := range tt.expectedSymParams {
+ sym, ok := result.Symbols[symName]
+ if !ok {
+ t.Errorf("Symbol %s not found for params check", symName)
+ continue
+ }
+ if sym.Params == nil {
+ t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
+ } else if *sym.Params != expectedParam {
+ t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
+ }
+ }
+ })
+ }
+}
+
+func TestAnalyzeRspamd(t *testing.T) {
+ tests := []struct {
+ name string
+ headers map[string]string
+ expectedScore float32
+ expectedThreshold float32
+ expectedIsSpam bool
+ expectedServer *string
+ expectedSymCount int
+ }{
+ {
+ name: "Full headers clean email",
+ headers: map[string]string{
+ "X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
+ "X-Rspamd-Score": "-3.91",
+ "X-Rspamd-Server": "mail.example.com",
+ },
+ expectedScore: -3.91,
+ expectedThreshold: 15.00,
+ expectedIsSpam: false,
+ expectedServer: func() *string { s := "mail.example.com"; return &s }(),
+ expectedSymCount: 1,
+ },
+ {
+ name: "X-Rspamd-Score overrides spamd result score",
+ headers: map[string]string{
+ "X-Spamd-Result": "default: False [2.00 / 15.00]",
+ "X-Rspamd-Score": "3.50",
+ },
+ expectedScore: 3.50,
+ expectedThreshold: 15.00,
+ expectedIsSpam: false,
+ },
+ {
+ name: "Spam email above threshold",
+ headers: map[string]string{
+ "X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
+ "X-Rspamd-Score": "16.00",
+ },
+ expectedScore: 16.00,
+ expectedThreshold: 15.00,
+ expectedIsSpam: true,
+ expectedSymCount: 1,
+ },
+ {
+ name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
+ headers: map[string]string{
+ "X-Rspamd-Score": "2.00",
+ },
+ expectedScore: 2.00,
+ expectedIsSpam: false,
+ },
+ {
+ name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
+ headers: map[string]string{
+ "X-Rspamd-Score": "7.00",
+ },
+ expectedScore: 7.00,
+ expectedIsSpam: true,
+ },
+ {
+ name: "Server header is trimmed",
+ headers: map[string]string{
+ "X-Rspamd-Score": "1.00",
+ "X-Rspamd-Server": " rspamd-01 ",
+ },
+ expectedScore: 1.00,
+ expectedServer: func() *string { s := "rspamd-01"; return &s }(),
+ },
+ }
+
+ analyzer := NewRspamdAnalyzer(nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ email := &EmailMessage{Header: make(mail.Header)}
+ for k, v := range tt.headers {
+ email.Header[k] = []string{v}
+ }
+
+ result := analyzer.AnalyzeRspamd(email)
+
+ if result == nil {
+ t.Fatal("Expected non-nil result")
+ }
+ if result.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
+ }
+ if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
+ t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
+ }
+ if result.IsSpam != tt.expectedIsSpam {
+ t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
+ }
+ if tt.expectedServer != nil {
+ if result.Server == nil {
+ t.Errorf("Server = nil, want %q", *tt.expectedServer)
+ } else if *result.Server != *tt.expectedServer {
+ t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
+ }
+ }
+ if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
+ t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
+ }
+ })
+ }
+}
+
+func TestCalculateRspamdScore(t *testing.T) {
+ tests := []struct {
+ name string
+ result *model.RspamdResult
+ expectedScore int
+ expectedGrade string
+ }{
+ {
+ name: "Nil result (rspamd not installed)",
+ result: nil,
+ expectedScore: 100,
+ expectedGrade: "",
+ },
+ {
+ name: "Score well below threshold",
+ result: &model.RspamdResult{
+ Score: -3.91,
+ Threshold: 15.00,
+ },
+ expectedScore: 100,
+ expectedGrade: "A+",
+ },
+ {
+ name: "Score at zero",
+ result: &model.RspamdResult{
+ Score: 0,
+ Threshold: 15.00,
+ },
+ // 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
+ expectedScore: 100,
+ expectedGrade: "A",
+ },
+ {
+ name: "Score at threshold (half of 2*threshold)",
+ result: &model.RspamdResult{
+ Score: 15.00,
+ Threshold: 15.00,
+ },
+ // 100 - round(15*100/(2*15)) = 100 - 50 = 50
+ expectedScore: 50,
+ },
+ {
+ name: "Score above 2*threshold",
+ result: &model.RspamdResult{
+ Score: 31.00,
+ Threshold: 15.00,
+ },
+ expectedScore: 0,
+ expectedGrade: "F",
+ },
+ {
+ name: "Score exactly at 2*threshold",
+ result: &model.RspamdResult{
+ Score: 30.00,
+ Threshold: 15.00,
+ },
+ // 100 - round(30*100/30) = 100 - 100 = 0
+ expectedScore: 0,
+ expectedGrade: "F",
+ },
+ }
+
+ analyzer := NewRspamdAnalyzer(nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ score, grade := analyzer.CalculateRspamdScore(tt.result)
+
+ if score != tt.expectedScore {
+ t.Errorf("Score = %d, want %d", score, tt.expectedScore)
+ }
+ if tt.expectedGrade != "" && grade != tt.expectedGrade {
+ t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
+ }
+ })
+ }
+}
+
+const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
+ BAYES_HAM(-3.00)[99%];
+ RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
+ R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
+ FROM_HAS_DN(0.00)[];
+ MIME_GOOD(-0.10)[text/plain];
+X-Rspamd-Score: -3.91
+X-Rspamd-Server: rspamd-01.example.com
+Date: Mon, 09 Mar 2026 10:00:00 +0000
+From: sender@example.com
+To: test@happydomain.org
+Subject: Test email
+Message-ID:
+MIME-Version: 1.0
+Content-Type: text/plain
+
+Hello world`
+
+func TestAnalyzeRspamdRealEmail(t *testing.T) {
+ email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
+ if err != nil {
+ t.Fatalf("Failed to parse email: %v", err)
+ }
+
+ analyzer := NewRspamdAnalyzer(nil)
+ result := analyzer.AnalyzeRspamd(email)
+
+ if result == nil {
+ t.Fatal("Expected non-nil result")
+ }
+ if result.IsSpam {
+ t.Error("Expected IsSpam=false")
+ }
+ if result.Score != -3.91 {
+ t.Errorf("Score = %v, want -3.91", result.Score)
+ }
+ if result.Threshold != 15.00 {
+ t.Errorf("Threshold = %v, want 15.00", result.Threshold)
+ }
+ if result.Server == nil || *result.Server != "rspamd-01.example.com" {
+ t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
+ }
+
+ expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
+ for _, sym := range expectedSymbols {
+ if _, ok := result.Symbols[sym]; !ok {
+ t.Errorf("Symbol %s not found", sym)
+ }
+ }
+
+ score, _ := analyzer.CalculateRspamdScore(result)
+ if score != 100 {
+ t.Errorf("CalculateRspamdScore = %d, want 100", score)
+ }
+}
+
diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go
index 03ab870..0baeab7 100644
--- a/pkg/analyzer/scoring.go
+++ b/pkg/analyzer/scoring.go
@@ -22,524 +22,80 @@
package analyzer
import (
- "fmt"
- "strings"
- "time"
-
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
-// DeliverabilityScorer aggregates all analysis results and computes overall score
-type DeliverabilityScorer struct{}
-
-// NewDeliverabilityScorer creates a new deliverability scorer
-func NewDeliverabilityScorer() *DeliverabilityScorer {
- return &DeliverabilityScorer{}
-}
-
-// ScoringResult represents the complete scoring result
-type ScoringResult struct {
- OverallScore float32
- Rating string // Excellent, Good, Fair, Poor, Critical
- AuthScore float32
- SpamScore float32
- BlacklistScore float32
- ContentScore float32
- HeaderScore float32
- Recommendations []string
- CategoryBreakdown map[string]CategoryScore
-}
-
-// CategoryScore represents score breakdown for a category
-type CategoryScore struct {
- Score float32
- MaxScore float32
- Percentage float32
- Status string // Pass, Warn, Fail
-}
-
-// CalculateScore computes the overall deliverability score from all analyzers
-func (s *DeliverabilityScorer) CalculateScore(
- authResults *api.AuthenticationResults,
- spamResult *SpamAssassinResult,
- rblResults *RBLResults,
- contentResults *ContentResults,
- email *EmailMessage,
-) *ScoringResult {
- result := &ScoringResult{
- CategoryBreakdown: make(map[string]CategoryScore),
- Recommendations: []string{},
- }
-
- // Calculate individual scores
- result.AuthScore = s.GetAuthenticationScore(authResults)
-
- spamAnalyzer := NewSpamAssassinAnalyzer()
- result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
-
- rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs)
- result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults)
-
- contentAnalyzer := NewContentAnalyzer(10 * time.Second)
- result.ContentScore = contentAnalyzer.GetContentScore(contentResults)
-
- // Calculate header quality score
- result.HeaderScore = s.calculateHeaderScore(email)
-
- // Calculate overall score (out of 10)
- result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
-
- // Ensure score is within bounds
- if result.OverallScore > 10.0 {
- result.OverallScore = 10.0
- }
- if result.OverallScore < 0.0 {
- result.OverallScore = 0.0
- }
-
- // Determine rating
- result.Rating = s.determineRating(result.OverallScore)
-
- // Build category breakdown
- result.CategoryBreakdown["Authentication"] = CategoryScore{
- Score: result.AuthScore,
- MaxScore: 3.0,
- Percentage: (result.AuthScore / 3.0) * 100,
- Status: s.getCategoryStatus(result.AuthScore, 3.0),
- }
-
- result.CategoryBreakdown["Spam Filters"] = CategoryScore{
- Score: result.SpamScore,
- MaxScore: 2.0,
- Percentage: (result.SpamScore / 2.0) * 100,
- Status: s.getCategoryStatus(result.SpamScore, 2.0),
- }
-
- result.CategoryBreakdown["Blacklists"] = CategoryScore{
- Score: result.BlacklistScore,
- MaxScore: 2.0,
- Percentage: (result.BlacklistScore / 2.0) * 100,
- Status: s.getCategoryStatus(result.BlacklistScore, 2.0),
- }
-
- result.CategoryBreakdown["Content Quality"] = CategoryScore{
- Score: result.ContentScore,
- MaxScore: 2.0,
- Percentage: (result.ContentScore / 2.0) * 100,
- Status: s.getCategoryStatus(result.ContentScore, 2.0),
- }
-
- result.CategoryBreakdown["Email Structure"] = CategoryScore{
- Score: result.HeaderScore,
- MaxScore: 1.0,
- Percentage: (result.HeaderScore / 1.0) * 100,
- Status: s.getCategoryStatus(result.HeaderScore, 1.0),
- }
-
- // Generate recommendations
- result.Recommendations = s.generateRecommendations(result)
-
- return result
-}
-
-// calculateHeaderScore evaluates email structural quality (0-1 point)
-func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 {
- if email == nil {
- return 0.0
- }
-
- score := float32(0.0)
- requiredHeaders := 0
- presentHeaders := 0
-
- // Check required headers (RFC 5322)
- headers := map[string]bool{
- "From": false,
- "Date": false,
- "Message-ID": false,
- }
-
- for header := range headers {
- requiredHeaders++
- if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
- headers[header] = true
- presentHeaders++
- }
- }
-
- // Score based on required headers (0.4 points)
- if presentHeaders == requiredHeaders {
- score += 0.4
- } else {
- score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders))
- }
-
- // Check recommended headers (0.3 points)
- recommendedHeaders := []string{"Subject", "To", "Reply-To"}
- recommendedPresent := 0
- for _, header := range recommendedHeaders {
- if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
- recommendedPresent++
- }
- }
- score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))
-
- // Check for proper MIME structure (0.2 points)
- if len(email.Parts) > 0 {
- score += 0.2
- }
-
- // Check Message-ID format (0.1 points)
- if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
- if s.isValidMessageID(messageID) {
- score += 0.1
- }
- }
-
- // Ensure score doesn't exceed 1.0
- if score > 1.0 {
- score = 1.0
- }
-
- return score
-}
-
-// isValidMessageID checks if a Message-ID has proper format
-func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool {
- // Basic check: should be in format <...@...>
- if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
- return false
- }
-
- // Remove angle brackets
- messageID = strings.TrimPrefix(messageID, "<")
- messageID = strings.TrimSuffix(messageID, ">")
-
- // Should contain @ symbol
- if !strings.Contains(messageID, "@") {
- return false
- }
-
- parts := strings.Split(messageID, "@")
- if len(parts) != 2 {
- return false
- }
-
- // Both parts should be non-empty
- return len(parts[0]) > 0 && len(parts[1]) > 0
-}
-
-// determineRating determines the rating based on overall score
-func (s *DeliverabilityScorer) determineRating(score float32) string {
+// ScoreToGrade converts a percentage score (0-100) to a letter grade
+func ScoreToGrade(score int) 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"
+ 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 "Critical"
+ return "F"
}
}
-// getCategoryStatus determines status for a category
-func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
- percentage := (score / maxScore) * 100
-
+// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation
+func ScoreToGradeKind(score int) string {
switch {
- case percentage >= 80.0:
- return "Pass"
- case percentage >= 50.0:
- return "Warn"
+ 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 "Fail"
+ return "F"
}
}
-// 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
+// ScoreToReportGrade converts a percentage score to an model.ReportGrade
+func ScoreToReportGrade(score int) model.ReportGrade {
+ return model.ReportGrade(ScoreToGrade(score))
}
-// GenerateHeaderChecks creates checks for email header quality
-func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
- var checks []api.Check
-
- if email == nil {
- return checks
+// gradeRank returns a numeric rank for a grade (lower = worse)
+func gradeRank(grade string) int {
+ switch grade {
+ case "A++":
+ return 7
+ case "A+":
+ return 6
+ case "A":
+ return 5
+ case "B":
+ return 4
+ case "C":
+ return 3
+ case "D":
+ return 2
+ case "E":
+ return 1
+ default:
+ return 0
}
-
- // 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",
+// MinGrade returns the minimal (worse) grade between the two given grades
+func MinGrade(a, b string) string {
+ if gradeRank(a) <= gradeRank(b) {
+ return a
}
-
- requiredHeaders := []string{"From", "Date", "Message-ID"}
- missing := []string{}
-
- for _, header := range requiredHeaders {
- if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
- missing = append(missing, header)
- }
- }
-
- if len(missing) == 0 {
- check.Status = api.CheckStatusPass
- check.Score = 0.4
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Message = "All required headers are present"
- check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
- } else {
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Severity = api.PtrTo(api.CheckSeverityCritical)
- check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
- check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
- details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
- check.Details = &details
- }
-
- return check
-}
-
-// generateRecommendedHeadersCheck checks for recommended headers
-func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
- check := api.Check{
- Category: api.Headers,
- Name: "Recommended Headers",
- }
-
- recommendedHeaders := []string{"Subject", "To", "Reply-To"}
- missing := []string{}
-
- for _, header := range recommendedHeaders {
- if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
- missing = append(missing, header)
- }
- }
-
- if len(missing) == 0 {
- check.Status = api.CheckStatusPass
- check.Score = 0.3
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Message = "All recommended headers are present"
- check.Advice = api.PtrTo("Your email includes all recommended headers")
- } else if len(missing) < len(recommendedHeaders) {
- check.Status = api.CheckStatusWarn
- check.Score = 0.15
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
- check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
- details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
- check.Details = &details
- } else {
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Message = "Missing all recommended headers"
- check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
- }
-
- return check
-}
-
-// generateMessageIDCheck validates Message-ID header
-func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check {
- check := api.Check{
- Category: api.Headers,
- Name: "Message-ID Format",
- }
-
- messageID := email.GetHeaderValue("Message-ID")
-
- if messageID == "" {
- check.Status = api.CheckStatusFail
- check.Score = 0.0
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Message = "Message-ID header is missing"
- check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
- } else if !s.isValidMessageID(messageID) {
- check.Status = api.CheckStatusWarn
- check.Score = 0.05
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Message = "Message-ID format is invalid"
- check.Advice = api.PtrTo("Use proper Message-ID format: ")
- check.Details = &messageID
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 0.1
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Message = "Message-ID is properly formatted"
- check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
- check.Details = &messageID
- }
-
- return check
-}
-
-// generateMIMEStructureCheck validates MIME structure
-func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
- check := api.Check{
- Category: api.Headers,
- Name: "MIME Structure",
- }
-
- if len(email.Parts) == 0 {
- check.Status = api.CheckStatusWarn
- check.Score = 0.0
- check.Severity = api.PtrTo(api.CheckSeverityLow)
- check.Message = "No MIME parts detected"
- check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
- } else {
- check.Status = api.CheckStatusPass
- check.Score = 0.2
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
- check.Advice = api.PtrTo("Your email has proper MIME structure")
-
- // Add details about parts
- partTypes := []string{}
- for _, part := range email.Parts {
- if part.ContentType != "" {
- partTypes = append(partTypes, part.ContentType)
- }
- }
- if len(partTypes) > 0 {
- details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
- check.Details = &details
- }
- }
-
- return check
-}
-
-// GetScoreSummary generates a human-readable summary of the score
-func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
- var summary strings.Builder
-
- summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating))
- summary.WriteString("Category Breakdown:\n")
- summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n",
- result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status))
- summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n",
- result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status))
- summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n",
- result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status))
- summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n",
- result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status))
- summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n",
- result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status))
-
- if len(result.Recommendations) > 0 {
- summary.WriteString("\nRecommendations:\n")
- for _, rec := range result.Recommendations {
- summary.WriteString(fmt.Sprintf(" %s\n", rec))
- }
- }
-
- return summary.String()
-}
-
-// GetAuthenticationScore calculates the authentication score (0-3 points)
-func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
- var score float32 = 0.0
-
- // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
- if results.Spf != nil {
- switch results.Spf.Result {
- case api.AuthResultResultPass:
- score += 1.0
- case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
- score += 0.5
- }
- }
-
- // DKIM: 1 point for at least one pass
- if results.Dkim != nil && len(*results.Dkim) > 0 {
- for _, dkim := range *results.Dkim {
- if dkim.Result == api.AuthResultResultPass {
- score += 1.0
- break
- }
- }
- }
-
- // DMARC: 1 point for pass
- if results.Dmarc != nil {
- switch results.Dmarc.Result {
- case api.AuthResultResultPass:
- score += 1.0
- }
- }
-
- // Cap at 3 points maximum
- if score > 3.0 {
- score = 3.0
- }
-
- return score
+ return b
}
diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go
deleted file mode 100644
index b28182d..0000000
--- a/pkg/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/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go
index 00cab21..96f60dd 100644
--- a/pkg/analyzer/spamassassin.go
+++ b/pkg/analyzer/spamassassin.go
@@ -22,12 +22,13 @@
package analyzer
import (
- "fmt"
+ "math"
"regexp"
"strconv"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
@@ -38,44 +39,34 @@ 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 {
+func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
- result := &SpamAssassinResult{
- TestDetails: make(map[string]SpamTestDetail),
+ // Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
+ _, hasStatus := headers["X-Spam-Status"]
+ _, hasScore := headers["X-Spam-Score"]
+ _, hasFlag := headers["X-Spam-Flag"]
+ if !hasStatus && !hasScore && !hasFlag {
+ return nil
+ }
+
+ result := &model.SpamAssassinResult{
+ TestDetails: make(map[string]model.SpamTestDetail),
}
// Parse X-Spam-Status header
- if statusHeader, ok := headers["X-Spam-Status"]; ok {
+ 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 = score
+ result.Score = float32(score)
}
}
@@ -86,13 +77,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
- result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
+ result.Report = utils.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 = strings.TrimSpace(versionHeader)
+ result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
}
return result
@@ -100,7 +91,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss
// 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) {
+func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
@@ -112,7 +103,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
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
+ result.Score = float32(score)
}
}
@@ -120,19 +111,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
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
+ result.RequiredScore = float32(required)
}
}
// Extract tests
- testsRe := regexp.MustCompile(`tests=([^\s]+)`)
+ testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`)
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
testsStr := matches[1]
// Tests can be comma or space separated
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
return r == ',' || r == ' '
})
- result.Tests = tests
+ result.Tests = &tests
}
}
@@ -140,17 +131,20 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
-// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
-// We split on '*' to separate individual tests.
-func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
- // The report header has been joined by mail.Header.Get(), so we split on '*'
- // Each segment starting with '*' is either a test line or continuation
+// 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 *model.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 == "" {
@@ -160,186 +154,76 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass
// 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 := model.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])
- detail := SpamTestDetail{
- Name: testName,
- Score: score,
- Description: description,
+ currentTestName = testName
+ currentDescription.Reset()
+ currentDescription.WriteString(description)
+
+ // Initialize with score
+ result.TestDetails[testName] = model.SpamTestDetail{
+ Name: testName,
+ Score: float32(score),
}
- result.TestDetails[testName] = detail
+ } 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 := model.SpamTestDetail{
+ Name: currentTestName,
+ Score: result.TestDetails[currentTestName].Score,
+ Description: &description,
+ }
+ result.TestDetails[currentTestName] = 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 {
+// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
+func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
if result == nil {
- return 0.0
+ 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
- required := result.RequiredScore
- if required == 0 {
- required = 5.0 // Default SpamAssassin threshold
- }
- // Calculate deliverability score
- if score <= 0 {
- return 2.0
- } else if score < required {
- // Linear scaling from 1.5 to 2.0 based on how negative/low the score is
- ratio := score / required
- return 1.5 + (0.5 * (1.0 - float32(ratio)))
- } else if score < required*2 {
- // Slightly above threshold
- return 1.0
- } else if score < required*3 {
- // Moderately high
- return 0.5
- }
-
- // Very high spam score
- return 0.0
-}
-
-// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
-func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
- var checks []api.Check
-
- if result == nil {
- checks = append(checks, api.Check{
- Category: api.Spam,
- Name: "SpamAssassin Analysis",
- Status: api.CheckStatusWarn,
- Score: 0.0,
- Message: "No SpamAssassin headers found",
- Severity: api.PtrTo(api.CheckSeverityMedium),
- Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
- })
- return checks
- }
-
- // Main spam score check
- mainCheck := a.generateMainSpamCheck(result)
- checks = append(checks, mainCheck)
-
- // Add checks for significant spam tests (score > 1.0 or < -1.0)
- for _, test := range result.Tests {
- if detail, ok := result.TestDetails[test]; ok {
- if detail.Score > 1.0 || detail.Score < -1.0 {
- check := a.generateTestCheck(detail)
- checks = append(checks, check)
- }
- }
- }
-
- return checks
-}
-
-// generateMainSpamCheck creates the main spam score check
-func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
- check := api.Check{
- Category: api.Spam,
- Name: "SpamAssassin Score",
- }
-
- score := result.Score
- required := result.RequiredScore
- if required == 0 {
- required = 5.0
- }
-
- delivScore := a.GetSpamAssassinScore(result)
- check.Score = delivScore
-
- // Determine status and message based on score
- if score <= 0 {
- check.Status = api.CheckStatusPass
- check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
- } else if score < required {
- check.Status = api.CheckStatusPass
- check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Advice = api.PtrTo("Your email passes spam filters")
- } else if score < required*1.5 {
- check.Status = api.CheckStatusWarn
- check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
- } else if score < required*2 {
- check.Status = api.CheckStatusWarn
- check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
+ // 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 {
- check.Status = api.CheckStatusFail
- check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
- check.Severity = api.PtrTo(api.CheckSeverityCritical)
- check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
+ // Linear scale between 0 and required threshold
+ percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore))))
+ return percentage, ScoreToGrade(percentage - 5)
}
-
- // Add details
- if len(result.Tests) > 0 {
- details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
- if len(result.Tests) > 5 {
- details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
- }
- check.Details = &details
- }
-
- return check
-}
-
-// generateTestCheck creates a check for a specific spam test
-func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
- check := api.Check{
- Category: api.Spam,
- Name: fmt.Sprintf("Spam Test: %s", detail.Name),
- }
-
- if detail.Score > 0 {
- // Negative indicator (increases spam score)
- if detail.Score > 2.0 {
- check.Status = api.CheckStatusFail
- check.Severity = api.PtrTo(api.CheckSeverityHigh)
- } else {
- check.Status = api.CheckStatusWarn
- check.Severity = api.PtrTo(api.CheckSeverityMedium)
- }
- check.Score = 0.0
- check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
- advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
- check.Advice = &advice
- } else {
- // Positive indicator (decreases spam score)
- check.Status = api.CheckStatusPass
- check.Score = 1.0
- check.Severity = api.PtrTo(api.CheckSeverityInfo)
- check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
- advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
- check.Advice = &advice
- }
-
- check.Details = &detail.Description
-
- return check
-}
-
-// min returns the minimum of two integers
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
}
diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go
index e7491db..d5e67a9 100644
--- a/pkg/analyzer/spamassassin_test.go
+++ b/pkg/analyzer/spamassassin_test.go
@@ -27,7 +27,8 @@ import (
"strings"
"testing"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
func TestParseSpamStatus(t *testing.T) {
@@ -35,8 +36,8 @@ func TestParseSpamStatus(t *testing.T) {
name string
header string
expectedIsSpam bool
- expectedScore float64
- expectedReq float64
+ expectedScore float32
+ expectedReq float32
expectedTests []string
}{
{
@@ -77,8 +78,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 := &model.SpamAssassinResult{
+ TestDetails: make(map[string]model.SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@@ -91,8 +92,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)
+ }
}
})
}
@@ -111,27 +116,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
- result := &SpamAssassinResult{
- TestDetails: make(map[string]SpamTestDetail),
+ result := &model.SpamAssassinResult{
+ TestDetails: make(map[string]model.SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
- expectedTests := map[string]SpamTestDetail{
+ expectedTests := map[string]model.SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
- Description: "Bayes spam probability is 99 to 100%",
+ Description: utils.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: utils.PtrTo("From address doesn't match envelope sender"),
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
- Description: "All mail servers are trusted",
+ Description: utils.PtrTo("All mail servers are trusted"),
},
}
@@ -144,8 +149,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)
}
}
}
@@ -153,56 +158,63 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
- result *SpamAssassinResult
- expectedScore float32
- minScore float32
- maxScore float32
+ result *model.SpamAssassinResult
+ expectedScore int
+ minScore int
+ maxScore int
}{
{
name: "Nil result",
result: nil,
- expectedScore: 0.0,
+ expectedScore: 100,
},
{
name: "Excellent score (negative)",
- result: &SpamAssassinResult{
+ result: &model.SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
- expectedScore: 2.0,
+ expectedScore: 100,
},
{
name: "Good score (below threshold)",
- result: &SpamAssassinResult{
+ result: &model.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: &model.SpamAssassinResult{
+ Score: 5.0,
+ RequiredScore: 5.0,
+ },
+ expectedScore: 0, // >= threshold = 0
+ },
+ {
+ name: "Above threshold (spam)",
+ result: &model.SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
- expectedScore: 1.0,
+ expectedScore: 0, // >= threshold = 0
},
{
name: "High spam score",
- result: &SpamAssassinResult{
+ result: &model.SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
- expectedScore: 0.5,
+ expectedScore: 0, // >= threshold = 0
},
{
name: "Very high spam score",
- result: &SpamAssassinResult{
+ result: &model.SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},
- expectedScore: 0.0,
+ expectedScore: 0, // >= threshold = 0
},
}
@@ -210,7 +222,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 {
@@ -230,7 +242,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
name string
headers map[string]string
expectedIsSpam bool
- expectedScore float64
+ expectedScore float32
expectedHasDetails bool
}{
{
@@ -296,86 +308,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{
@@ -389,98 +321,6 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
}
}
-func TestGenerateMainSpamCheck(t *testing.T) {
- analyzer := NewSpamAssassinAnalyzer()
-
- tests := []struct {
- name string
- score float64
- required float64
- expectedStatus api.CheckStatus
- }{
- {"Excellent", -1.0, 5.0, api.CheckStatusPass},
- {"Good", 2.0, 5.0, api.CheckStatusPass},
- {"Borderline", 6.0, 5.0, api.CheckStatusWarn},
- {"High", 8.0, 5.0, api.CheckStatusWarn},
- {"Very High", 15.0, 5.0, api.CheckStatusFail},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := &SpamAssassinResult{
- Score: tt.score,
- RequiredScore: tt.required,
- }
-
- check := analyzer.generateMainSpamCheck(result)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Category != api.Spam {
- t.Errorf("Category = %v, want %v", check.Category, api.Spam)
- }
- if !strings.Contains(check.Message, "spam score") {
- t.Error("Message should contain 'spam score'")
- }
- })
- }
-}
-
-func TestGenerateTestCheck(t *testing.T) {
- analyzer := NewSpamAssassinAnalyzer()
-
- tests := []struct {
- name string
- detail SpamTestDetail
- expectedStatus api.CheckStatus
- }{
- {
- name: "High penalty test",
- detail: SpamTestDetail{
- Name: "BAYES_99",
- Score: 5.0,
- Description: "Bayes spam probability is 99 to 100%",
- },
- expectedStatus: api.CheckStatusFail,
- },
- {
- name: "Medium penalty test",
- detail: SpamTestDetail{
- Name: "HTML_MESSAGE",
- Score: 1.5,
- Description: "Contains HTML",
- },
- expectedStatus: api.CheckStatusWarn,
- },
- {
- name: "Positive test",
- detail: SpamTestDetail{
- Name: "ALL_TRUSTED",
- Score: -2.0,
- Description: "All mail servers are trusted",
- },
- expectedStatus: api.CheckStatusPass,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- check := analyzer.generateTestCheck(tt.detail)
-
- if check.Status != tt.expectedStatus {
- t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
- }
- if check.Category != api.Spam {
- t.Errorf("Category = %v, want %v", check.Category, api.Spam)
- }
- if !strings.Contains(check.Name, tt.detail.Name) {
- t.Errorf("Check name should contain test name %s", tt.detail.Name)
- }
- })
- }
-}
-
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
@@ -542,24 +382,26 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
}
// Validate score (should be -0.1)
- expectedScore := -0.1
+ var expectedScore float32 = -0.1
if result.Score != expectedScore {
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
}
// Validate required score (should be 5.0)
- expectedRequired := 5.0
+ var expectedRequired float32 = 5.0
if result.RequiredScore != expectedRequired {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
}
// Validate version
- if !strings.Contains(result.Version, "SpamAssassin") {
- t.Errorf("Version should contain 'SpamAssassin', got: %s", result.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 {
+ if len(*result.Tests) == 0 {
t.Error("Expected tests to be extracted, got none")
}
@@ -572,7 +414,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
"SPF_HELO_NONE": true,
}
- for _, testName := range result.Tests {
+ for _, testName := range *result.Tests {
if expectedTests[testName] {
t.Logf("Found expected test: %s", testName)
}
@@ -586,11 +428,11 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
// Log what we actually got for debugging
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
for name, detail := range result.TestDetails {
- t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
+ t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description)
}
// Define expected test details with their scores
- expectedTestDetails := map[string]float64{
+ expectedTestDetails := map[string]float32{
"SPF_PASS": -0.0,
"SPF_HELO_NONE": 0.0,
"DKIM_VALID": -0.1,
@@ -611,43 +453,15 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
if detail.Score != expectedScore {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
}
- if detail.Description == "" {
+ if detail.Description == nil || *detail.Description == "" {
t.Errorf("Test %s should have a description", testName)
}
}
// Test GetSpamAssassinScore
- score := analyzer.GetSpamAssassinScore(result)
- if score != 2.0 {
- t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
- }
-
- // Test GenerateSpamAssassinChecks
- checks := analyzer.GenerateSpamAssassinChecks(result)
- if len(checks) < 1 {
- t.Fatal("Expected at least 1 check, got none")
- }
-
- // Main check should be PASS with excellent score
- mainCheck := checks[0]
- if mainCheck.Status != api.CheckStatusPass {
- t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
- }
- if mainCheck.Category != api.Spam {
- t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
- }
- if !strings.Contains(mainCheck.Message, "spam score") {
- t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
- }
- if mainCheck.Score != 2.0 {
- t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
- }
-
- // Log all checks for debugging
- t.Logf("Generated %d checks:", len(checks))
- for i, check := range checks {
- t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
- i+1, check.Name, check.Message, check.Score, check.Status)
+ score, _ := analyzer.CalculateSpamAssassinScore(result)
+ if score != 100 {
+ t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
}
}
diff --git a/web/package-lock.json b/web/package-lock.json
index 3fbf1f1..27e6fc1 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -13,31 +13,65 @@
"bootstrap-icons": "^1.13.1"
},
"devDependencies": {
- "@eslint/compat": "^1.4.0",
- "@eslint/js": "^9.36.0",
- "@hey-api/openapi-ts": "0.85.2",
+ "@eslint/compat": "^2.0.0",
+ "@eslint/js": "^10.0.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": "^22",
- "eslint": "^9.36.0",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "@types/node": "^24.0.0",
+ "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
- "globals": "^16.4.0",
+ "globals": "^17.0.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
- "typescript": "^5.9.2",
+ "typescript": "^6.0.0",
"typescript-eslint": "^8.44.1",
- "vite": "^7.1.10",
+ "vite": "^8.0.0",
"vitest": "^3.2.4"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
- "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
@@ -52,9 +86,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
- "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
@@ -69,9 +103,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz",
- "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
@@ -86,9 +120,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
- "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
@@ -103,9 +137,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
- "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
@@ -120,9 +154,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz",
- "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
@@ -137,9 +171,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
- "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
@@ -154,9 +188,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz",
- "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
@@ -171,9 +205,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
- "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
@@ -188,9 +222,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
- "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
@@ -205,9 +239,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
- "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
@@ -222,9 +256,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz",
- "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
@@ -239,9 +273,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
- "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
@@ -256,9 +290,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
- "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
@@ -273,9 +307,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
- "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
@@ -290,9 +324,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
- "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
@@ -307,9 +341,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz",
- "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
@@ -324,9 +358,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
- "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
@@ -341,9 +375,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
- "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
@@ -358,9 +392,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
- "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
@@ -375,9 +409,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz",
- "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
@@ -392,9 +426,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
- "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
@@ -409,9 +443,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
- "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
@@ -426,9 +460,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz",
- "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
@@ -443,9 +477,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
- "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
@@ -460,9 +494,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
- "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
@@ -477,9 +511,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
- "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "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": {
@@ -509,9 +543,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "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": {
@@ -519,19 +553,19 @@
}
},
"node_modules/@eslint/compat": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz",
- "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.0.tgz",
+ "integrity": "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0"
+ "@eslint/core": "^1.2.1"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"peerDependencies": {
- "eslint": "^8.40 || 9"
+ "eslint": "^8.40 || 9 || 10"
},
"peerDependenciesMeta": {
"eslint": {
@@ -540,128 +574,99 @@
}
},
"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==",
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.7",
+ "@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
- "minimatch": "^3.1.2"
+ "minimatch": "^10.2.4"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
- "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0"
+ "@eslint/core": "^1.2.1"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
- "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"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/eslintrc": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
- "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
- "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.0",
- "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": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
- "version": "9.38.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
- "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
}
},
"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==",
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
- "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
+ "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0",
+ "@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@hey-api/codegen-core": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.2.0.tgz",
- "integrity": "sha512-c7VjBy/8ed0EVLNgaeS9Xxams1Tuv/WK/b4xXH3Qr4wjzYeJUtxOcoP8YdwNLavqKP8pGiuctjX2Z1Pwc4jMgQ==",
+ "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": "^18.18.0 || ^20.9.0 || >=22.10.0"
+ "node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
@@ -671,9 +676,9 @@
}
},
"node_modules/@hey-api/json-schema-ref-parser": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.0.tgz",
- "integrity": "sha512-BMnIuhVgNmSudadw1GcTsP18Yk5l8FrYrg/OSYNxz0D2E0vf4D5e4j5nUbuY8MU6p1vp7ev0xrfP6A/NWazkzQ==",
+ "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": {
@@ -690,27 +695,27 @@
}
},
"node_modules/@hey-api/openapi-ts": {
- "version": "0.85.2",
- "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.85.2.tgz",
- "integrity": "sha512-pNu+DOtjeXiGhMqSQ/mYadh6BuKR/QiucVunyA2P7w2uyxkfCJ9sHS20Y72KHXzB3nshKJ9r7JMirysoa50SJg==",
+ "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.2.0",
- "@hey-api/json-schema-ref-parser": "1.2.0",
+ "@hey-api/codegen-core": "^0.3.2",
+ "@hey-api/json-schema-ref-parser": "1.2.1",
"ansi-colors": "4.1.3",
- "c12": "3.3.0",
+ "c12": "3.3.1",
"color-support": "1.1.3",
- "commander": "13.0.0",
+ "commander": "14.0.1",
"handlebars": "4.7.8",
- "open": "10.1.2",
+ "open": "10.2.0",
"semver": "7.7.2"
},
"bin": {
- "openapi-ts": "bin/index.cjs"
+ "openapi-ts": "bin/run.js"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
@@ -720,29 +725,43 @@
}
},
"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==",
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.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==",
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@humanfs/core": "^0.19.1",
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.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",
@@ -828,42 +847,33 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
+ "@tybys/wasm-util": "^0.10.1"
},
- "engines": {
- "node": ">= 8"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
}
},
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "node_modules/@oxc-project/types": {
+ "version": "0.130.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
+ "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@polka/url": {
@@ -884,10 +894,292 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
+ "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
+ "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
+ "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
+ "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
+ "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
+ "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
+ "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
+ "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
+ "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
+ "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
+ "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
+ "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
+ "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
+ "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
+ "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
- "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
"cpu": [
"arm"
],
@@ -899,9 +1191,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
- "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
"cpu": [
"arm64"
],
@@ -913,9 +1205,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
- "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"cpu": [
"arm64"
],
@@ -927,9 +1219,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
- "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
"cpu": [
"x64"
],
@@ -941,9 +1233,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
- "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
"cpu": [
"arm64"
],
@@ -955,9 +1247,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
- "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
"cpu": [
"x64"
],
@@ -969,13 +1261,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
- "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
"cpu": [
"arm"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -983,13 +1278,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
- "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
"cpu": [
"arm"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -997,13 +1295,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
- "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1011,13 +1312,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
- "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1025,13 +1329,33 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
- "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
"cpu": [
"loong64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1039,13 +1363,33 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
- "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
"cpu": [
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1053,13 +1397,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
- "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
"cpu": [
"riscv64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1067,13 +1414,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
- "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
"cpu": [
"riscv64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1081,13 +1431,16 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
- "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
"cpu": [
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1095,13 +1448,16 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
- "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1109,9 +1465,26 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
- "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
"cpu": [
"x64"
],
@@ -1119,13 +1492,13 @@
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
- "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
"cpu": [
"arm64"
],
@@ -1137,9 +1510,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
- "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
"cpu": [
"arm64"
],
@@ -1151,9 +1524,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
- "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
"cpu": [
"ia32"
],
@@ -1165,9 +1538,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
- "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
"cpu": [
"x64"
],
@@ -1179,9 +1552,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
- "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
"cpu": [
"x64"
],
@@ -1193,16 +1566,16 @@
]
},
"node_modules/@standard-schema/spec": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
- "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "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.6",
- "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
- "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1220,25 +1593,23 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.47.2",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.2.tgz",
- "integrity": "sha512-mbUomaJTiADTrq6GT4ZvQ7v1rs0S+wXGMzrjFwjARAKMEF8FpOUmz2uEJ4M9WMJMQOXCMHpKFzJfdjo9O7M22A==",
+ "version": "2.60.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
+ "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
"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.3.2",
+ "devalue": "^5.8.1",
"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",
+ "set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
"bin": {
@@ -1249,64 +1620,60 @@
},
"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",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
- "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
+ "typescript": "^5.3.3 || ^6.0.0",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
+ },
+ "typescript": {
+ "optional": true
}
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz",
- "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
+ "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
- "debug": "^4.4.1",
"deepmerge": "^4.3.1",
- "magic-string": "^0.30.17",
- "vitefu": "^1.1.1"
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.2"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
- "svelte": "^5.0.0",
- "vite": "^6.3.0 || ^7.0.0"
+ "svelte": "^5.46.4",
+ "vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
- "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz",
- "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==",
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
- "debug": "^4.4.1"
- },
- "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"
+ "tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
- "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
+ "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": "*"
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
@@ -1323,10 +1690,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "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==",
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
@@ -1338,32 +1712,37 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.18.11",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz",
- "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==",
+ "version": "24.12.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
+ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.16.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
- "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
+ "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.46.1",
- "@typescript-eslint/type-utils": "8.46.1",
- "@typescript-eslint/utils": "8.46.1",
- "@typescript-eslint/visitor-keys": "8.46.1",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/type-utils": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1373,9 +1752,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.46.1",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "@typescript-eslint/parser": "^8.59.3",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -1389,18 +1768,17 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
- "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
+ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.46.1",
- "@typescript-eslint/types": "8.46.1",
- "@typescript-eslint/typescript-estree": "8.46.1",
- "@typescript-eslint/visitor-keys": "8.46.1",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1410,20 +1788,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
- "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
+ "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.46.1",
- "@typescript-eslint/types": "^8.46.1",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.59.3",
+ "@typescript-eslint/types": "^8.59.3",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1433,18 +1811,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
- "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
+ "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.1",
- "@typescript-eslint/visitor-keys": "8.46.1"
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1455,9 +1833,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
- "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
+ "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1468,21 +1846,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
- "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
+ "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.1",
- "@typescript-eslint/typescript-estree": "8.46.1",
- "@typescript-eslint/utils": "8.46.1",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1492,14 +1870,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
- "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
+ "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1511,22 +1889,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
- "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
+ "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.46.1",
- "@typescript-eslint/tsconfig-utils": "8.46.1",
- "@typescript-eslint/types": "8.46.1",
- "@typescript-eslint/visitor-keys": "8.46.1",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.59.3",
+ "@typescript-eslint/tsconfig-utils": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1536,46 +1913,33 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.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==",
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+ "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
+ "bin": {
+ "semver": "bin/semver.js"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
+ "node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
- "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
+ "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.46.1",
- "@typescript-eslint/types": "8.46.1",
- "@typescript-eslint/typescript-estree": "8.46.1"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1585,19 +1949,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
- "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
+ "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.1",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.59.3",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1624,33 +1988,6 @@
"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",
@@ -1723,12 +2060,11 @@
}
},
"node_modules/acorn": {
- "version": "8.15.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
- "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1747,9 +2083,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1773,22 +2109,6 @@
"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",
@@ -1797,9 +2117,9 @@
"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==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1827,11 +2147,14 @@
}
},
"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==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
},
"node_modules/bootstrap": {
"version": "5.3.8",
@@ -1869,27 +2192,16 @@
"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==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
+ "balanced-match": "^4.0.2"
},
"engines": {
- "node": ">=8"
+ "node": "18 || 20 || >=22"
}
},
"node_modules/bundle-name": {
@@ -1909,19 +2221,19 @@
}
},
"node_modules/c12": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz",
- "integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==",
+ "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.2",
+ "dotenv": "^17.2.3",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
- "jiti": "^2.5.1",
+ "jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.0.0",
@@ -1947,16 +2259,6 @@
"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",
@@ -1974,27 +2276,10 @@
"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.1",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
- "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+ "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": {
@@ -2037,26 +2322,6 @@
"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",
@@ -2068,26 +2333,19 @@
}
},
"node_modules/commander": {
- "version": "13.0.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz",
- "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==",
+ "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": ">=18"
+ "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==",
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
@@ -2185,9 +2443,9 @@
}
},
"node_modules/default-browser": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
- "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+ "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2202,9 +2460,9 @@
}
},
"node_modules/default-browser-id": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz",
- "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
+ "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": {
@@ -2228,9 +2486,9 @@
}
},
"node_modules/defu": {
- "version": "6.1.4",
- "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
- "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
@@ -2241,17 +2499,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/devalue": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz",
- "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==",
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"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==",
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -2269,9 +2537,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
- "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2282,32 +2550,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.11",
- "@esbuild/android-arm": "0.25.11",
- "@esbuild/android-arm64": "0.25.11",
- "@esbuild/android-x64": "0.25.11",
- "@esbuild/darwin-arm64": "0.25.11",
- "@esbuild/darwin-x64": "0.25.11",
- "@esbuild/freebsd-arm64": "0.25.11",
- "@esbuild/freebsd-x64": "0.25.11",
- "@esbuild/linux-arm": "0.25.11",
- "@esbuild/linux-arm64": "0.25.11",
- "@esbuild/linux-ia32": "0.25.11",
- "@esbuild/linux-loong64": "0.25.11",
- "@esbuild/linux-mips64el": "0.25.11",
- "@esbuild/linux-ppc64": "0.25.11",
- "@esbuild/linux-riscv64": "0.25.11",
- "@esbuild/linux-s390x": "0.25.11",
- "@esbuild/linux-x64": "0.25.11",
- "@esbuild/netbsd-arm64": "0.25.11",
- "@esbuild/netbsd-x64": "0.25.11",
- "@esbuild/openbsd-arm64": "0.25.11",
- "@esbuild/openbsd-x64": "0.25.11",
- "@esbuild/openharmony-arm64": "0.25.11",
- "@esbuild/sunos-x64": "0.25.11",
- "@esbuild/win32-arm64": "0.25.11",
- "@esbuild/win32-ia32": "0.25.11",
- "@esbuild/win32-x64": "0.25.11"
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escape-string-regexp": {
@@ -2324,34 +2592,30 @@
}
},
"node_modules/eslint": {
- "version": "9.38.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
- "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
+ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"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.1",
- "@eslint/core": "^0.16.0",
- "@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.38.0",
- "@eslint/plugin-kit": "^0.4.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.6.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.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",
+ "ajv": "^6.14.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",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
@@ -2361,8 +2625,7 @@
"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",
+ "minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -2370,7 +2633,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
@@ -2401,9 +2664,9 @@
}
},
"node_modules/eslint-plugin-svelte": {
- "version": "3.12.5",
- "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.5.tgz",
- "integrity": "sha512-4KRG84eAHQfYd9OjZ1K7sCHy0nox+9KwT+s5WCCku3jTim5RV4tVENob274nCwIaApXsYPKAUAZFBxKZ3Wyfjw==",
+ "version": "3.17.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.17.1.tgz",
+ "integrity": "sha512-NyiXHtS3Ni7e532RBwS9OXlMKDIrENg3gY+/+ODjZzQx2xhU3NlJ+nIl1a93iUUQeiJL3lS8KLmY+W8hklzweQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2425,7 +2688,7 @@
"url": "https://github.com/sponsors/ota-meshi"
},
"peerDependencies": {
- "eslint": "^8.57.1 || ^9.0.0",
+ "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0",
"svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
@@ -2434,31 +2697,46 @@
}
}
},
+ "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==",
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"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==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -2472,27 +2750,27 @@
"license": "MIT"
},
"node_modules/espree": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
- "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
- "acorn": "^8.15.0",
+ "acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^4.2.1"
+ "eslint-visitor-keys": "^5.0.1"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
- "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "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": {
@@ -2503,13 +2781,21 @@
}
},
"node_modules/esrap": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
- "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
+ "version": "2.2.9",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
+ "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
}
},
"node_modules/esrecurse": {
@@ -2556,9 +2842,9 @@
}
},
"node_modules/expect-type": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
- "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
+ "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": {
@@ -2566,9 +2852,9 @@
}
},
"node_modules/exsolve": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
- "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"dev": true,
"license": "MIT"
},
@@ -2579,36 +2865,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"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",
@@ -2623,16 +2879,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fastq": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
- "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2664,19 +2910,6 @@
"node": ">=16.0.0"
}
},
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2709,9 +2942,9 @@
}
},
"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==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -2762,9 +2995,9 @@
}
},
"node_modules/globals": {
- "version": "16.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
- "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "version": "17.6.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
+ "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2774,13 +3007,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
@@ -2803,16 +3029,6 @@
"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",
@@ -2823,23 +3039,6 @@
"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",
@@ -2908,16 +3107,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -2929,9 +3118,9 @@
}
},
"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==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2952,9 +3141,9 @@
"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==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2969,9 +3158,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "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": {
@@ -3043,6 +3232,279 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -3077,16 +3539,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "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==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
@@ -3098,63 +3553,29 @@
"license": "MIT"
},
"node_modules/magic-string": {
- "version": "0.30.19",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
- "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+ "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/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
+ "brace-expansion": "^5.0.5"
},
"engines": {
- "node": ">=8.6"
- }
- },
- "node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
+ "node": "18 || 20 || >=22"
},
"funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "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": "*"
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -3195,9 +3616,9 @@
"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==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -3235,25 +3656,41 @@
"license": "MIT"
},
"node_modules/nypm": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
- "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
+ "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.2",
+ "citty": "^0.2.2",
"pathe": "^2.0.3",
- "pkg-types": "^2.3.0",
- "tinyexec": "^1.0.1"
+ "tinyexec": "^1.1.1"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
- "node": "^14.16.0 || >=16.10.0"
+ "node": ">=18"
}
},
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+ "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",
@@ -3262,16 +3699,16 @@
"license": "MIT"
},
"node_modules/open": {
- "version": "10.1.2",
- "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz",
- "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==",
+ "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",
- "is-wsl": "^3.1.0"
+ "wsl-utils": "^0.1.0"
},
"engines": {
"node": ">=18"
@@ -3330,19 +3767,6 @@
"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",
@@ -3381,9 +3805,9 @@
}
},
"node_modules/perfect-debounce": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz",
- "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==",
+ "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"
},
@@ -3395,12 +3819,11 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3409,21 +3832,21 @@
}
},
"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==",
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
"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==",
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -3440,7 +3863,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3481,9 +3903,9 @@
}
},
"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==",
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
@@ -3545,9 +3967,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
- "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
+ "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": {
@@ -3569,12 +3991,11 @@
}
},
"node_modules/prettier": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
- "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3586,9 +4007,9 @@
}
},
"node_modules/prettier-plugin-svelte": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz",
- "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz",
+ "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -3606,27 +4027,6 @@
"node": ">=6"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -3652,31 +4052,44 @@
"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==",
+ "node_modules/rolldown": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
+ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.130.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
"engines": {
- "node": ">=4"
- }
- },
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.1",
+ "@rolldown/binding-darwin-arm64": "1.0.1",
+ "@rolldown/binding-darwin-x64": "1.0.1",
+ "@rolldown/binding-freebsd-x64": "1.0.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.1",
+ "@rolldown/binding-linux-arm64-musl": "1.0.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-musl": "1.0.1",
+ "@rolldown/binding-openharmony-arm64": "1.0.1",
+ "@rolldown/binding-wasm32-wasi": "1.0.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.1",
+ "@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/rollup": {
- "version": "4.52.5",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
- "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3690,31 +4103,41 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.52.5",
- "@rollup/rollup-android-arm64": "4.52.5",
- "@rollup/rollup-darwin-arm64": "4.52.5",
- "@rollup/rollup-darwin-x64": "4.52.5",
- "@rollup/rollup-freebsd-arm64": "4.52.5",
- "@rollup/rollup-freebsd-x64": "4.52.5",
- "@rollup/rollup-linux-arm-gnueabihf": "4.52.5",
- "@rollup/rollup-linux-arm-musleabihf": "4.52.5",
- "@rollup/rollup-linux-arm64-gnu": "4.52.5",
- "@rollup/rollup-linux-arm64-musl": "4.52.5",
- "@rollup/rollup-linux-loong64-gnu": "4.52.5",
- "@rollup/rollup-linux-ppc64-gnu": "4.52.5",
- "@rollup/rollup-linux-riscv64-gnu": "4.52.5",
- "@rollup/rollup-linux-riscv64-musl": "4.52.5",
- "@rollup/rollup-linux-s390x-gnu": "4.52.5",
- "@rollup/rollup-linux-x64-gnu": "4.52.5",
- "@rollup/rollup-linux-x64-musl": "4.52.5",
- "@rollup/rollup-openharmony-arm64": "4.52.5",
- "@rollup/rollup-win32-arm64-msvc": "4.52.5",
- "@rollup/rollup-win32-ia32-msvc": "4.52.5",
- "@rollup/rollup-win32-x64-gnu": "4.52.5",
- "@rollup/rollup-win32-x64-msvc": "4.52.5",
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
"fsevents": "~2.3.2"
}
},
+ "node_modules/rollup/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/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -3728,30 +4151,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -3779,9 +4178,9 @@
}
},
"node_modules/set-cookie-parser": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
- "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
+ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
"dev": true,
"license": "MIT"
},
@@ -3864,19 +4263,6 @@
"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",
@@ -3890,37 +4276,25 @@
"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.41.0",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.41.0.tgz",
- "integrity": "sha512-mP3vFFv5OUM5JN189+nJVW74kQ1dGqUrXTEzvCEVZqessY0GxZDls1nWVvt4Sxyv2USfQvAZO68VRaeIZvpzKg==",
+ "version": "5.55.7",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
+ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
"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",
+ "@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
- "aria-query": "^5.3.1",
+ "aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
+ "devalue": "^5.8.1",
"esm-env": "^1.2.1",
- "esrap": "^2.1.0",
+ "esrap": "^2.2.4",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -3931,9 +4305,9 @@
}
},
"node_modules/svelte-check": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz",
- "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==",
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz",
+ "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3955,9 +4329,9 @@
}
},
"node_modules/svelte-eslint-parser": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz",
- "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.1.tgz",
+ "integrity": "sha512-hhvSH6kRj46UzrBVO5TaotD+Iuvruj5ccKBcO4wAhVcPTLmIc/c32D8UllBTYO0on4LzYuM0rNzf1lM/gBlkSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3966,11 +4340,12 @@
"espree": "^10.0.0",
"postcss": "^8.4.49",
"postcss-scss": "^4.0.9",
- "postcss-selector-parser": "^7.0.0"
+ "postcss-selector-parser": "^7.0.0",
+ "semver": "^7.7.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0",
- "pnpm": "10.18.3"
+ "pnpm": "10.33.0"
},
"funding": {
"url": "https://github.com/sponsors/ota-meshi"
@@ -3984,6 +4359,54 @@
}
}
},
+ "node_modules/svelte-eslint-parser/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/svelte-eslint-parser/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/svelte-eslint-parser/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/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3992,21 +4415,24 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
- "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"dev": true,
- "license": "MIT"
+ "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==",
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -4045,19 +4471,6 @@
"node": ">=14.0.0"
}
},
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -4069,9 +4482,9 @@
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4081,6 +4494,14 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4095,12 +4516,11 @@
}
},
"node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4110,16 +4530,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.46.1",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
- "integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
+ "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.46.1",
- "@typescript-eslint/parser": "8.46.1",
- "@typescript-eslint/typescript-estree": "8.46.1",
- "@typescript-eslint/utils": "8.46.1"
+ "@typescript-eslint/eslint-plugin": "8.59.3",
+ "@typescript-eslint/parser": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4129,8 +4549,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uglify-js": {
@@ -4148,9 +4568,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "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"
},
@@ -4172,14 +4592,114 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "7.1.10",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
- "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
+ "version": "8.0.13",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
+ "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "esbuild": "^0.25.0",
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.1",
+ "tinyglobby": "^0.2.16"
+ },
+ "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",
+ "@vitejs/devtools": "^0.1.18",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.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
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "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/vite-node/node_modules/vite": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
+ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -4247,33 +4767,10 @@
}
}
},
- "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==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -4282,7 +4779,7 @@
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
@@ -4363,6 +4860,33 @@
}
}
},
+ "node_modules/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/node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
@@ -4370,6 +4894,81 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/vitest/node_modules/vite": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
+ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
+ "dev": true,
+ "license": "MIT",
+ "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/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4420,6 +5019,38 @@
"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.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "extraneous": true,
+ "license": "ISC",
+ "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",
diff --git a/web/package.json b/web/package.json
index d0a2578..90b545e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,24 +16,24 @@
"generate:api": "openapi-ts"
},
"devDependencies": {
- "@eslint/compat": "^1.4.0",
- "@eslint/js": "^9.36.0",
- "@hey-api/openapi-ts": "0.85.2",
+ "@eslint/compat": "^2.0.0",
+ "@eslint/js": "^10.0.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": "^22",
- "eslint": "^9.36.0",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "@types/node": "^24.0.0",
+ "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
- "globals": "^16.4.0",
+ "globals": "^17.0.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
- "typescript": "^5.9.2",
+ "typescript": "^6.0.0",
"typescript-eslint": "^8.44.1",
- "vite": "^7.1.10",
+ "vite": "^8.0.0",
"vitest": "^3.2.4"
},
"dependencies": {
diff --git a/web/routes.go b/web/routes.go
index 754c1b2..056115d 100644
--- a/web/routes.go
+++ b/web/routes.go
@@ -23,9 +23,10 @@ package web
import (
"encoding/json"
+ "flag"
+ "fmt"
"io"
"io/fs"
- "io/ioutil"
"log"
"net/http"
"net/url"
@@ -41,12 +42,38 @@ import (
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 !cfg.DisableTestList {
+ appConfig["test_list_enabled"] = true
+ }
+
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
@@ -66,6 +93,13 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/", serveOrReverse("/", cfg))
+ router.GET("/blacklist/", serveOrReverse("/", cfg))
+ router.GET("/blacklist/:ip", serveOrReverse("/", cfg))
+ router.GET("/domain/", serveOrReverse("/", cfg))
+ router.GET("/domain/:domain", serveOrReverse("/", cfg))
+ router.GET("/test/", serveOrReverse("/", cfg))
+ router.GET("/test/:testid", serveOrReverse("/", cfg))
+ router.GET("/history/", serveOrReverse("/", cfg))
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", serveOrReverse("", cfg))
@@ -85,7 +119,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
- if forced_url != "" {
+ if forced_url != "" && forced_url != "/" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
@@ -114,14 +148,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
}
}
- v, _ := ioutil.ReadAll(resp.Body)
+ v, _ := io.ReadAll(resp.Body)
- v2 := strings.Replace(string(v), "", "{{ .Head }}", 1)
+ 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{
- "Head": CustomHeadHTML,
+ "Body": CustomBodyHTML,
+ "Head": CustomHeadHTML,
+ "RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
@@ -139,16 +175,18 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
- v, _ := ioutil.ReadAll(f)
+ v, _ := io.ReadAll(f)
- v2 := strings.Replace(string(v), "", "{{ .Head }}", 1)
+ 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{
- "Head": CustomHeadHTML,
+ "Body": CustomBodyHTML,
+ "Head": CustomHeadHTML,
+ "RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
diff --git a/web/src/app.css b/web/src/app.css
index ddae5b6..dca80a5 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -1,6 +1,9 @@
:root {
--bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135;
+ --bs-link-color-rgb: 28, 180, 135;
+ --bs-link-hover-color-rgb: 17, 112, 84;
+ --bs-tertiary-bg: #e7e8e8;
}
body {
@@ -8,6 +11,10 @@ body {
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
+.bg-tertiary {
+ background-color: var(--bs-tertiary-bg);
+}
+
/* Animations */
@keyframes fadeIn {
from {
@@ -74,14 +81,21 @@ body {
/* Custom card styling */
.card {
- border: none;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
-.card:hover {
+.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);
}
diff --git a/web/src/app.html b/web/src/app.html
index 1966776..9e3bf88 100644
--- a/web/src/app.html
+++ b/web/src/app.html
@@ -3,9 +3,38 @@